WebAssembly Modules skill for Claude Code
Ship wasm only where it pays, and keep it small. Claude first checks whether WebAssembly will actually beat JavaScript for the job, then picks the matching target: wasm-bindgen, Emscripten, wasip1 or wasip2 components. It designs coarse JS boundaries that survive memory growth, loads modules in workers with correct headers, and uses a bundled analyzer to find the largest sections and functions and enforce a gzip size budget.
- name
webassembly-modules - size218 lines · ≈2,550 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and ships WebAssembly modules from Rust, C/C++, Go and Zig for the browser, Node and WASI runtimes, choosing the right target (wasm32-unknown-unknown with wasm-bindgen, Emscripten, wasip1, wasip2 components with WIT), keeping the JS boundary cheap, loading with instantiateStreaming in workers, and cutting binary size with release profiles, wasm-opt and a bundled section analyzer. Use when compiling code to .wasm, wiring wasm-bindgen or Emscripten exports, running modules in wasmtime or Node, debugging memory views or MIME errors, or when a wasm bundle is too large or slow. Try asking
- Compile this Rust image filter to wasm and call it from a web worker
- Our .wasm is 3 MB gzipped — find what is taking the space and shrink it
- Build a WASI plugin system where untrusted modules run in wasmtime with limits
Use it when
- Compiling Rust, C/C++, Go or Zig to .wasm
- Wiring JS to wasm exports with wasm-bindgen, Emscripten or wasm_exec.js
- Running modules or components in wasmtime, Node or jco
- A wasm bundle is too big, slow to load, or fails with MIME or memory errors
Skip it when
- General JavaScript bundle size work — use bundle-analysis
- Rust borrow checker problems unrelated to wasm — use rust-ownership
How to check the result
Tests pass in the real host (headless browser or wasmtime), the analyzer shows no debug sections and a gzip size under budget, and a benchmark proves the speedup including boundary costs.
The common mistake it prevents
Caching a Uint8Array view over wasm memory across calls; when memory grows the old view detaches and reads return garbage or throw.
Done well means: WebAssembly is used only where it pays (CPU-bound work, an existing native library, sandboxed plugins), the target matches where it runs, calls across the JS boundary are few and coarse, the shipped binary is release-optimized and stripped with a size budget enforced in CI, and the module is tested in the real host.
Decide first
| Situation | Verdict |
|---|---|
| Hot numeric loop, codec, image/audio processing, parser, crypto, physics | Good fit |
| Reusing a C/C++/Rust library in the browser instead of rewriting it | Good fit |
| Untrusted plugins or user code on a server | Good fit (WASI + wasmtime with fuel/epoch limits) |
| DOM-heavy UI, lots of small calls into JS | Poor fit; boundary overhead eats the gain |
| Code that is mostly network I/O | Poor fit; no speedup, bigger bundle |
Benchmark the JS version first. Optimized modern JS is often within 1.5-3x of wasm for plain logic; the win comes from SIMD, predictable memory layout, and existing native code.
Target matrix
| Runs in | Language | Target / toolchain | Glue |
|---|---|---|---|
| Browser or bundler | Rust | wasm32-unknown-unknown | wasm-bindgen (+ wasm-bindgen-cli, or wasm-pack) |
| Browser | C/C++ | Emscripten (emcc) | Generated ES module loader |
| CLI tool / server runtime | Rust, C, Go, Zig | wasm32-wasip1 / GOOS=wasip1 / wasi-sdk | WASI preview 1 imports |
| Components, polyglot plugins | Rust, others with wit-bindgen | wasm32-wasip2 + WIT interfaces | Component model; jco to run in JS |
| Browser, Go code | Go | GOOS=js GOARCH=wasm (or TinyGo for size) | wasm_exec.js from the same Go version |
| Freestanding, no host API | Zig, C | wasm32-freestanding | Hand-written imports/exports |
WASI 0.2 (components, WIT) is the stable baseline for server-side components. WASI 0.3 (native async) was ratified in June 2026 and needs Wasmtime 46+; guest toolchain support is still arriving, so target 0.2 unless the user’s runtime and toolchain confirm 0.3.
Rust for the browser
# Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
[profile.release]
opt-level = "z" # or "s"; measure both, and "3" for speed-critical code
lto = true
codegen-units = 1
panic = "abort"
strip = true
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
for px in pixels.chunks_exact_mut(4) {
let y = ((px[0] as u32 * 77 + px[1] as u32 * 150 + px[2] as u32 * 29) >> 8) as u8;
px[0] = y; px[1] = y; px[2] = y;
}
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/imgfx.wasm
wasm-opt -Oz -o pkg/imgfx_bg.opt.wasm pkg/imgfx_bg.wasm # binaryen
wasm-bindgen-climust be the exact same version as thewasm-bindgencrate inCargo.lock; a mismatch fails with a schema version error. Install withcargo install wasm-bindgen-cli --version <lockfile version>.- wasm-pack still works as a wrapper, but the old rustwasm org was archived in 2025 and
wasm-pack moved to a new maintainer; calling
wasm-bindgendirectly avoids depending on it. wasm-bindgen itself lives on in its own organization. - Add
console_error_panic_hookin debug builds so panics print a Rust message instead ofunreachable executed. - Do not use
wee_alloc; it is unmaintained and has known leaks. The default allocator is fine; measure before swapping.
C/C++ with Emscripten
emcc src/codec.c -O3 -flto \
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker \
-sALLOW_MEMORY_GROWTH=1 \
-sEXPORTED_FUNCTIONS=_decode,_malloc,_free \
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8 \
-o dist/codec.mjs
- Exported C functions need the leading underscore in
EXPORTED_FUNCTIONS; mark themEMSCRIPTEN_KEEPALIVEor they are dead-stripped. - Use
-Ozfor size,-O3for speed; add-sASSERTIONS=1in debug builds only. - For WASI instead of the browser, use wasi-sdk:
clang --target=wasm32-wasip1 --sysroot=$WASI_SDK/share/wasi-sysroot -O2 -o tool.wasm tool.c.
Go and Zig
GOOS=wasip1 GOARCH=wasm go build -o tool.wasm . # WASI; //go:wasmexport for exports (Go 1.24+)
GOOS=js GOARCH=wasm go build -o app.wasm . # browser; copy $(go env GOROOT)/lib/wasm/wasm_exec.js
tinygo build -o app.wasm -target wasm -opt z . # 10-50x smaller, partial stdlib/reflection support
zig build-exe src/main.zig -target wasm32-wasi -O ReleaseSmall
Standard Go output starts around 2 MB because it ships the runtime and GC. If size matters for a browser bundle, use TinyGo or a different language for that module.
The JS boundary
- Every call crosses a boundary; strings are copied and re-encoded (UTF-16 <-> UTF-8). Design coarse APIs: pass a whole buffer and process it in one call, not per element.
- Share bulk data through linear memory: allocate in wasm, write via a
Uint8Arrayview, call, read results back. - Any wasm call that allocates can grow memory, which detaches existing
ArrayBufferviews. Re-create views (new Uint8Array(memory.buffer, ptr, len)) after every call that might allocate; never cache them across calls. - Free what you allocate across the boundary: wasm-bindgen objects exported to JS need
.free()(orusing/FinalizationRegistryas a backstop, not the plan). - Use
serde-wasm-bindgenfor structured data only off the hot path; for hot paths pass numbers and typed arrays.
Loading in the browser
// worker.js: keep heavy wasm work off the main thread
import init, { grayscale } from './pkg/imgfx.js';
await init(); // fetches imgfx_bg.wasm with instantiateStreaming
self.onmessage = ({ data }) => {
const pixels = new Uint8Array(data);
grayscale(pixels);
self.postMessage(pixels.buffer, [pixels.buffer]); // transfer, do not copy
};
- Serve
.wasmasContent-Type: application/wasm, orinstantiateStreamingfails and falls back (or throws) depending on the loader. - Enable brotli/gzip for
.wasm; set long cache lifetimes with content-hashed names. - Threads (
SharedArrayBuffer, wasm threads, Rust rayon via wasm-bindgen-rayon) require cross-origin isolation:Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. ConfirmcrossOriginIsolated === true. - CSP needs
'wasm-unsafe-eval'inscript-srcto compile wasm. - SIMD (
-C target-feature=+simd128in Rust,-msimd128in clang) is supported in all current major browsers; keep a scalar build only if you must support old engines.
WASI runtimes and components
wasmtime run --dir ./data::/data tool.wasm -- /data/input.txt # capabilities are explicit
cargo build --release --target wasm32-wasip2 # Rust component
wasm-tools component wit target/wasm32-wasip2/release/plugin.wasm # inspect its interface
jco transpile plugin.wasm -o dist/plugin # run a component from JS
- WASI grants nothing by default: directories (
--dir), environment (--env) and network must be granted explicitly. Grant the minimum. - Hosting untrusted modules: set fuel or epoch interruption and memory limits in the embedding runtime; a guest infinite loop otherwise pins a thread.
- Define plugin contracts in WIT and generate bindings (
wit-bindgen,cargo component) instead of hand-rolled pointer/length conventions.
Size and speed workflow
- [ ] 1. Build release with the size profile above (or emcc -Oz / tinygo -opt z)
- [ ] 2. python3 scripts/wasm_sections.py pkg/app_bg.wasm --top 20
- [ ] 3. Strip debug/name sections from shipped builds; keep an unstripped copy for profiling
- [ ] 4. Attack the largest functions: formatting machinery, panics with messages, generic bloat, serde
- [ ] 5. wasm-opt -Oz (or -O3 for speed), rerun step 2, compare gzip size
- [ ] 6. Add the budget to CI: python3 scripts/wasm_sections.py app.wasm --max-kb 250
The script lists section sizes, imports grouped by module (flags WASI, wasm-bindgen,
Go and Emscripten glue), exports, debug sections, the gzip size, and the largest
functions by name when a name section exists. It exits 1 when the gzip size exceeds
--max-kb, 2 for invalid input. For deeper attribution use twiggy top or
wasm-tools on an unstripped build.
Common size wins in Rust: avoid format!/{:?} in release paths, panic = "abort",
replace serde_json with a lighter parser for small payloads, and check generic
functions instantiated for many types (twiggy monos).
Gotchas
wasm32-unknown-unknownhas no filesystem, clock or randomness. Crates likegetrandomneed their wasm/JS feature enabled;std::time::Instantpanics (useweb-timeorjs-sys::Date).- Linear memory never shrinks; a spike to 500 MB stays allocated until the instance is dropped. Reuse buffers; recreate the instance for one-off huge jobs.
- Memory limit is 4 GiB for wasm32 (often less in browsers, especially mobile Safari). Memory64 exists but support is uneven; do not rely on it for the web.
- Recent Rust releases enable newer wasm features (such as reference types and multi-value) by default; very old runtimes may reject the module. Check the runtime’s feature support before lowering features with target-feature flags.
- Debugging: build with debug info and use Chrome DevTools with the C/C++ DWARF extension, or source maps; stack traces from stripped builds are function indices only.
Verification
- Tests run in the real host:
wasm-bindgen-testin headless browsers for web targets (wasm-pack test --headless --chromeorwasm-bindgen-test-runner), wasmtime for WASI (CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test --target wasm32-wasip1). scripts/wasm_sections.pyconfirms no debug sections in the shipped artifact and the gzip size is within budget.- A benchmark against the JS (or native) baseline shows the speedup that justified wasm, measured including boundary and data-copy costs.
- The production server returns
application/wasmand compression headers (curl -sI https://host/app.wasm).
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/wasm_sections.py
#!/usr/bin/env python3
"""Report what is inside a .wasm core module: section sizes, imports, exports,
custom sections, gzip size, and the largest functions (when a name section exists).
Usage:
python3 wasm_sections.py <module.wasm> [--top N] [--max-kb KB]
--top N number of largest functions to list (default 15)
--max-kb KB exit 1 if the gzip-compressed size exceeds KB kilobytes (size budget gate)
Exit codes: 0 ok, 1 budget exceeded, 2 unreadable or invalid file.
Standard library only.
"""
from __future__ import annotations
import gzip
import sys
from collections import Counter
from pathlib import Path
SECTION_NAMES = {
0: "custom", 1: "type", 2: "import", 3: "function", 4: "table", 5: "memory",
6: "global", 7: "export", 8: "start", 9: "element", 10: "code", 11: "data",
12: "datacount", 13: "tag",
}
KINDS = {0: "func", 1: "table", 2: "memory", 3: "global", 4: "tag"}
class Reader:
def __init__(self, data: bytes, pos: int = 0, end: int | None = None):
self.d, self.p = data, pos
self.end = len(data) if end is None else end
def byte(self) -> int:
if self.p >= self.end:
raise ValueError("unexpected end of data")
b = self.d[self.p]
self.p += 1
return b
def u32(self) -> int:
result = shift = 0
while True:
b = self.byte()
result |= (b & 0x7F) << shift
if not b & 0x80:
return result
shift += 7
if shift > 63:
raise ValueError("LEB128 too long")
def s33(self) -> int:
result = shift = 0
while True:
b = self.byte()
result |= (b & 0x7F) << shift
shift += 7
if not b & 0x80:
if b & 0x40:
result -= 1 << shift
return result
def name(self) -> str:
n = self.u32()
s = self.d[self.p:self.p + n]
self.p += n
return s.decode("utf-8", errors="replace")
def limits(self) -> None:
flags = self.byte()
self.u32()
if flags & 0x01:
self.u32()
def reftype(self) -> None:
b = self.byte()
if b in (0x63, 0x64): # (ref null ht) / (ref ht) from the GC proposal
self.s33()
valtype = reftype # numeric types are single bytes; ref types handled above
def human(n: int) -> str:
return f"{n / 1024:.1f} KiB" if n >= 1024 else f"{n} B"
def analyze(path: Path, top: int) -> tuple[int, int]:
data = path.read_bytes()
if len(data) < 8 or data[:4] != b"\x00asm":
raise ValueError("not a WebAssembly binary (missing \\0asm magic)")
version = data[4:8]
total = len(data)
gz = len(gzip.compress(data, compresslevel=9))
print(f"{path}: {human(total)} raw, {human(gz)} gzip -9")
if version != b"\x01\x00\x00\x00":
print(f"Header version {version.hex()} is not a core module (likely a component). "
"Inspect components with `wasm-tools component wit` or `wasm-tools print`.")
return total, gz
r = Reader(data, 8)
sizes: Counter[str] = Counter()
customs: list[tuple[str, int]] = []
imports: list[tuple[str, str, str]] = []
exports: list[tuple[str, str]] = []
imported_funcs = 0
body_sizes: list[int] = []
func_names: dict[int, str] = {}
while r.p < len(data):
sid = r.byte()
size = r.u32()
start, end = r.p, r.p + size
label = SECTION_NAMES.get(sid, f"unknown({sid})")
sub = Reader(data, start, end)
if sid == 0:
cname = sub.name()
customs.append((cname, size))
label = f"custom:{cname}"
if cname == "name":
try:
while sub.p < end:
sub_id = sub.byte()
sub_size = sub.u32()
sub_end = sub.p + sub_size
if sub_id == 1:
for _ in range(sub.u32()):
idx = sub.u32()
func_names[idx] = sub.name()
sub.p = sub_end
except ValueError:
pass # malformed name section: sizes are still valid
elif sid == 2:
for _ in range(sub.u32()):
mod, field, kind = sub.name(), sub.name(), sub.byte()
imports.append((mod, field, KINDS.get(kind, str(kind))))
if kind == 0:
sub.u32(); imported_funcs += 1
elif kind == 1:
sub.reftype(); sub.limits()
elif kind == 2:
sub.limits()
elif kind == 3:
sub.valtype(); sub.byte()
elif kind == 4:
sub.byte(); sub.u32()
elif sid == 7:
for _ in range(sub.u32()):
ename, kind = sub.name(), sub.byte()
sub.u32()
exports.append((ename, KINDS.get(kind, str(kind))))
elif sid == 10:
for _ in range(sub.u32()):
bsize = sub.u32()
body_sizes.append(bsize)
sub.p += bsize
sizes[label] += size
r.p = end
print("\nSections (largest first):")
for label, size in sizes.most_common():
print(f" {label:<28} {human(size):>12} {100 * size / total:5.1f}%")
debug = [(n, s) for n, s in customs if n.startswith(".debug") or n in ("sourceMappingURL", "external_debug_info")]
if debug:
dsize = sum(s for _, s in debug)
print(f"\nDebug info: {human(dsize)} in {len(debug)} section(s). Strip for release "
"(wasm-opt --strip-debug, wasm-tools strip, or build profile settings).")
if "name" in dict(customs):
print("Name section present: useful for profiling, strippable for release (wasm-opt --strip-debug removes it).")
by_module = Counter(m for m, _, _ in imports)
print(f"\nImports: {len(imports)} ({imported_funcs} functions)")
for mod, count in by_module.most_common():
print(f" {mod:<40} {count}")
hints = []
if any(m.startswith("wasi_snapshot_preview1") for m in by_module):
hints.append("WASI preview 1 module (wasip1): runs on wasmtime/wasmer/Node WASI, not directly in browsers")
if any(m.startswith("wasi:") for m in by_module):
hints.append("imports WASI 0.2+ interfaces: should be a component, not a core module")
if any("wbindgen" in m or "wbg" == m for m in by_module):
hints.append("wasm-bindgen glue module: load through the generated JS, not raw WebAssembly.instantiate")
if any(m == "go" or m == "gojs" for m in by_module):
hints.append("Go js/wasm module: needs wasm_exec.js from the same Go version")
if "env" in by_module:
hints.append("imports from 'env': host must supply these (Emscripten/C toolchains)")
for h in hints:
print(f" note: {h}")
print(f"\nExports: {len(exports)}")
for ename, kind in exports[:40]:
print(f" {kind:<7} {ename}")
if len(exports) > 40:
print(f" ... {len(exports) - 40} more")
if body_sizes:
ranked = sorted(enumerate(body_sizes), key=lambda t: t[1], reverse=True)[:top]
code_total = sum(body_sizes)
print(f"\nLargest functions ({len(body_sizes)} defined, code total {human(code_total)}):")
for local_idx, bsize in ranked:
fidx = imported_funcs + local_idx
name = func_names.get(fidx, f"func[{fidx}]")
if len(name) > 90:
name = name[:87] + "..."
print(f" {human(bsize):>10} {100 * bsize / code_total:5.1f}% {name}")
if not func_names:
print(" (no name section: build with debug names or use twiggy/wasm-tools for attribution)")
return total, gz
def main() -> int:
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print(__doc__.strip())
return 2
top, max_kb, path = 15, None, None
i = 0
while i < len(args):
if args[i] == "--top":
top = int(args[i + 1]); i += 2
elif args[i] == "--max-kb":
max_kb = float(args[i + 1]); i += 2
else:
path = Path(args[i]); i += 1
if path is None or not path.is_file():
print(f"wasm_sections: file not found: {path}", file=sys.stderr)
return 2
try:
_, gz = analyze(path, top)
except (ValueError, IndexError) as exc:
print(f"wasm_sections: invalid module: {exc}", file=sys.stderr)
return 2
if max_kb is not None:
limit = max_kb * 1024
status = "OK" if gz <= limit else "OVER BUDGET"
print(f"\nBudget: gzip {human(gz)} vs limit {max_kb:g} KiB -> {status}")
if gz > limit:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.