Building Your First Wasm Module with AssemblyScript
WasmHub Team
February 19, 2025 · 6 min read
One of the most common barriers to WebAssembly adoption is the requirement to learn a systems programming language like Rust or C++. AssemblyScript removes that barrier by offering a TypeScript-like syntax that compiles directly to WebAssembly. If you already know TypeScript, you can be writing Wasm in minutes.
This tutorial walks you through creating a real image processing function — a grayscale converter — and running it in the browser with a meaningful speedup over the equivalent JavaScript.
What Is AssemblyScript?
AssemblyScript is a subset of TypeScript designed to compile to WebAssembly. It looks and feels like TypeScript, but with some important differences:
- Types are mandatory and map directly to Wasm primitives (
i32,f64,u8, etc.) - There's no
any, noundefined, and no JavaScript runtime - Standard library types like
Array,String, andMapare reimplemented in WebAssembly - The garbage collector is a lightweight Wasm-native implementation
It won't run in Node.js or the browser as TypeScript — it compiles to Wasm, which then runs in either environment.
Step 1: Project Setup
Create a new project and install the AssemblyScript compiler:
mkdir wasm-grayscale && cd wasm-grayscale
npm init -y
npm install --save-dev assemblyscript
npx asinit .asinit scaffolds the project structure for you:
wasm-grayscale/
├── assembly/
│ └── index.ts ← Your AssemblyScript source
├── build/ ← Compiled .wasm output (generated)
├── tests/
│ └── index.js
├── asconfig.json
└── package.json
The asconfig.json controls compiler targets. The default gives you both a debug build (readable, with source maps) and a release build (optimized):
{
"targets": {
"debug": {
"sourceMap": true,
"debug": true
},
"release": {
"optimizeLevel": 3,
"shrinkLevel": 1
}
}
}Step 2: Writing the AssemblyScript Module
Open assembly/index.ts and replace its contents with a grayscale converter. We'll work directly on a flat Uint8ClampedArray-style buffer — the same format the browser's ImageData API uses (RGBA, 4 bytes per pixel):
// assembly/index.ts
/**
* Convert an RGBA pixel buffer to grayscale in-place.
* @param ptr Offset into the module's memory where the buffer starts
* @param len Total byte length of the buffer (width × height × 4)
*/
export function grayscale(ptr: i32, len: i32): void {
let i: i32 = 0
while (i < len) {
const r = load<u8>(ptr + i)
const g = load<u8>(ptr + i + 1)
const b = load<u8>(ptr + i + 2)
// Luminance formula: weighted average matching human perception
const luma = u8(0.299 * f32(r) + 0.587 * f32(g) + 0.114 * f32(b))
store<u8>(ptr + i, luma)
store<u8>(ptr + i + 1, luma)
store<u8>(ptr + i + 2, luma)
// Alpha channel (ptr + i + 3) is left unchanged
i += 4
}
}
/**
* Allocate a block of memory inside the Wasm module.
* Returns the pointer (offset) to the allocated block.
*/
export function alloc(size: i32): i32 {
return heap.alloc(size) as i32
}
/**
* Free a previously allocated block.
*/
export function dealloc(ptr: i32, size: i32): void {
heap.free(changetype<usize>(ptr))
}A few AssemblyScript-specific things to notice:
load<u8>(ptr)andstore<u8>(ptr, val)read/write raw bytes in linear memory — the Wasm equivalent of pointer arithmeticf32(r)is an explicit cast; AssemblyScript requires these where TypeScript would be implicitheap.alloc/heap.freeare from AssemblyScript's built-in memory management
Step 3: Compiling to WebAssembly
npm run asbuildThis runs the AssemblyScript compiler (asc) and produces two files in build/:
release.wasm— The optimized binary you'll shiprelease.wasm.map— Source map for debugging
Check the output size:
ls -lh build/release.wasm
# → around 1-3 KB for this simple module — tiny!Step 4: Loading the Module in the Browser
Create a simple index.html at the project root:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Wasm Grayscale</title>
</head>
<body>
<input type="file" id="file-input" accept="image/*" />
<canvas id="canvas"></canvas>
<p id="timing"></p>
<script type="module" src="app.js"></script>
</body>
</html>Now create app.js to load the Wasm module and wire it up:
// app.js
// 1. Instantiate the module — WebAssembly.instantiateStreaming is the
// preferred method: it compiles the binary while it's still downloading.
const { instance } = await WebAssembly.instantiateStreaming(
fetch('./build/release.wasm')
)
const { grayscale, alloc, dealloc, memory } = instance.exports
// 2. Handle file input
document.getElementById('file-input').addEventListener('change', async (e) => {
const file = e.target.files[0]
if (!file) return
// Decode the image into an ImageData object via OffscreenCanvas
const bitmap = await createImageBitmap(file)
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
canvas.width = bitmap.width
canvas.height = bitmap.height
ctx.drawImage(bitmap, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const bytes = imageData.data // Uint8ClampedArray, RGBA
// 3. Allocate space inside Wasm linear memory
const ptr = alloc(bytes.byteLength)
// 4. Copy the pixel data into Wasm memory
new Uint8Array(memory.buffer, ptr, bytes.byteLength).set(bytes)
// 5. Call the Wasm function
const t0 = performance.now()
grayscale(ptr, bytes.byteLength)
const t1 = performance.now()
// 6. Copy the result back out and paint it
bytes.set(new Uint8Array(memory.buffer, ptr, bytes.byteLength))
ctx.putImageData(imageData, 0, 0)
// 7. Clean up
dealloc(ptr, bytes.byteLength)
document.getElementById('timing').textContent =
`Wasm grayscale: ${(t1 - t0).toFixed(2)} ms for ${canvas.width}×${canvas.height}px`
})Serve the directory with any static file server (the Wasm MIME type requires HTTP, not file://):
npx serve .
# Open http://localhost:3000Step 5: Comparing with Pure JavaScript
For context, here's the equivalent JavaScript implementation:
function grayscaleJS(imageData) {
const d = imageData.data
for (let i = 0; i < d.length; i += 4) {
const luma = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]
d[i] = d[i + 1] = d[i + 2] = luma
}
}On a 4000×3000 pixel image (48 MB of pixel data), typical results:
| Implementation | Time |
|---|---|
| JavaScript (interpreted) | ~180 ms |
| JavaScript (JIT-warmed) | ~35 ms |
| AssemblyScript Wasm | ~18 ms |
| Wasm + SIMD (manual) | ~5 ms |
The Wasm version runs consistently fast from the first call. JavaScript needs several iterations before the JIT produces comparably optimized code — a real disadvantage for one-shot operations like processing a single uploaded image.
Where to Go Next
You now have a working AssemblyScript project that compiles to Wasm and integrates with a browser application. From here you can:
- Add SIMD instructions — AssemblyScript supports
v128SIMD operations; rewriting the inner loop withi8x16operations can give another 4–8× speedup - Publish to npm —
asbuildgenerates a loader-friendly output; pair it with@assemblyscript/loaderfor ergonomic TypeScript bindings - Explore the standard library — AssemblyScript ships with
Map,Set,Array,String,Math, and typed array implementations tuned for Wasm - Try WASI output — Pass
--target wasito compile a module that runs on the command line with Wasmtime or Wasmer
AssemblyScript is one of the lowest-friction paths into the Wasm ecosystem. Give it a try next time you have a hot loop that TypeScript can't make fast enough.