The previous six lessons are about JavaScript talking to the browser: storing, requesting, syncing, caching, formatting. This one is about something different: code that is not JavaScript running on the same page, inside the same engine, at near-native speed. WebAssembly —Wasm to its friends— is a portable binary format that makes it possible to bring programs written in C, C++, Rust or Go to the web. Thanks to it there are video editors, game engines, CAD tools and complete databases running inside a tab. In this lesson you will understand what it is and, above all, what it is not; you will see its execution model and why it only understands numbers; you will load and instantiate a module from JavaScript; you will meet the real toolchains for each language; and you will apply it all to a concrete Taller Nómada case —a simulation of 200,000 task assignment combinations— to finish by doing the only thing that settles the argument: measuring whether it is worth it. Let me give away the conclusion, because it is the most important lesson: it hardly ever is, and Nómada Tasks does not need it.
Contents
- What WebAssembly is
- What WebAssembly is NOT
- Why it exists: predictable performance and reusable code
- The execution model: module, instance, memory and table
- Why it only understands numbers
- The
.wattext format versus the.wasmbinary - Loading and instantiating from JavaScript
- Crossing the boundary: exporting, importing and sharing memory
- The real toolchains: Rust, C/C++ and AssemblyScript
- Real use cases
- JavaScript versus Wasm: an honest comparison
- WASI and the component model
- The Taller Nómada case: heavy planning
- Measuring whether it is worth it
- Common Mistakes and Tips
- Exercises
- Conclusion
- What WebAssembly is
WebAssembly is a binary instruction format for a stack-based virtual machine. Four adjectives define it:
- Binary: it is not text parsed like JavaScript, but bytes with a fixed structure that the browser can validate and compile very quickly.
- Portable: the same
.wasmworks identically in any browser, operating system and processor architecture. - Safe: it runs in the same sandbox as JavaScript, with no access to the file system, the network or the process memory beyond what you explicitly give it.
- Fast: it compiles to machine code and reaches near-native speeds.
The key idea, and the most misread one:
WebAssembly does not replace JavaScript. It runs in the same engine, alongside JavaScript, and the two call each other. It is a companion, not a substitute.
In a modern browser, your app.js and a .wasm module live together in the same tab, share the same thread (unless you use workers) and are invoked like ordinary functions.
It has also been a W3C standard since 2019, supported in every modern browser since 2017. It is not experimental.
- What WebAssembly is NOT
This section matters more than the previous one, because almost every misunderstanding comes from here.
| Myth | Reality |
|---|---|
| "It is the replacement for JavaScript" | They coexist. Wasm cannot even touch the DOM without going through JavaScript |
| "Everything is faster in Wasm" | Only intensive computation. Manipulating the DOM or making requests is as slow or slower because of the cost of crossing the boundary |
| "It is a way of hiding my code" | The binary is downloaded all the same and decompiled with public tools. It is not obfuscation or protection |
| "I can access the file system" | Not in the browser. It is in the same sandbox, and it only does what JavaScript allows it to |
| "It is a programming language" | It is a compilation target format. Nobody writes it by hand except to learn |
| "It is useful for any web application" | For the vast majority —Nómada Tasks included— it adds nothing |
And the most relevant practical limitation: Wasm has no direct access to the DOM. If a module wants to change the text of a card, it has to call a JavaScript function that you passed in as an import. That means an application whose main job is painting an interface —like ours— gains nothing, because the bottleneck is not in the computation.
- Why it exists: predictable performance and reusable code
Wasm was born to solve two concrete problems.
Predictable performance. JavaScript engines are extraordinarily fast thanks to just-in-time compilation: they analyze the code while it runs, infer the types, generate optimized machine code. But that optimization is speculative, and it can be undone:
function add(a, b) { return a + b; }
add(1, 2); // the engine optimizes assuming integers
add(1.5, 2.5); // still fine: numbers
add('a', 'b'); // ← DEOPTIMIZATION: it had assumed numbers, now there are stringsThat phenomenon is called deoptimization, and it makes JavaScript performance excellent but variable. WebAssembly, by contrast, has static types, no garbage collector in its core and no speculation: its performance is predictable, which in a game engine at 60 fps matters more than the average speed.
Reusing existing code. There are decades of libraries written in C and C++ —video codecs, cryptography, physics engines, image processing, SQLite— representing millions of hours of work. Rewriting them in JavaScript would be absurd. Compiling them to Wasm brings them to the web as they are.
flowchart LR
subgraph Sources
R["Rust"]
C["C / C++"]
A["AssemblyScript"]
G["Go, Zig, Swift…"]
end
R --> W[".wasm module"]
C --> W
A --> W
G --> W
W --> M["Browser engine<br/>(the same one that runs JS)"]
JS["JavaScript"] --> M
M --> CPU["Machine code"]
- The execution model: module, instance, memory and table
Four concepts, and it is worth telling them apart properly because the names are similar.
| Concept | What it is | JavaScript analogy |
|---|---|---|
Module (WebAssembly.Module) |
The compiled, stateless code. It can be reused and cached | A class |
Instance (WebAssembly.Instance) |
A module with its memory and its state, ready to run | An object created with new |
Memory (WebAssembly.Memory) |
A contiguous, resizable block of bytes | A giant ArrayBuffer |
Table (WebAssembly.Table) |
An array of function references | An array of functions, for indirect calls |
Linear memory is the central piece and the most alien to someone coming from JavaScript. It is literally an ArrayBuffer: a strip of bytes numbered from 0, with no structure. There are no objects, no strings, no arrays: only bytes that the module interprets according to the type it expects at each position.
flowchart TB
subgraph Page["Browser tab"]
JS["JavaScript<br/>objects, strings, GC"]
subgraph Inst["Wasm instance"]
F["Exported functions"]
MEM["Linear memory<br/>(ArrayBuffer of bytes)"]
TAB["Function table"]
end
end
JS -->|"calls exports.f(3, 4)"| F
F -->|"reads and writes"| MEM
JS <-->|"new Uint8Array(memory.buffer)"| MEM
F -->|"calls imports.notify()"| JS
The important thing in that diagram: JavaScript can read and write Wasm's memory directly, through typed views (Uint8Array, Float64Array…) over the same ArrayBuffer. There is no copy. That shared access is the only way of passing large amounts of data between the two sides efficiently.
Wasm has only four numeric types in its base version: i32, i64, f32 and f64 (32- and 64-bit integers and floats). Nothing else. No booleans, no characters, no real pointers: a pointer is simply an i32 representing an offset inside the linear memory.
- Why it only understands numbers
From the above follows the most practical consequence of the whole lesson: passing a string to Wasm is not passing a string. You have to serialize it.
To send 'Carpentry workshop quote' to a module you have to: encode it to UTF-8 bytes, reserve space in the linear memory, write those bytes there, and pass the function the offset and the length, two numbers.
/** Writes a string into Wasm's memory and returns where it is and how much space it takes. */
function writeString(instance, text) {
const bytes = new TextEncoder().encode(text); // string → UTF-8 bytes
// The module must export an allocator; here we assume a simple one
const pointer = instance.exports.alloc(bytes.length);
const memory = new Uint8Array(instance.exports.memory.buffer);
memory.set(bytes, pointer); // copies byte by byte
return { pointer, length: bytes.length };
}
/** And the way back */
function readString(instance, pointer, length) {
const memory = new Uint8Array(instance.exports.memory.buffer, pointer, length);
return new TextDecoder('utf-8').decode(memory);
}That back and forth is the cost of crossing the boundary, and it explains why Wasm does not always win:
| What you pass | Cost |
|---|---|
A number (i32, f64) |
Almost zero: it goes straight through |
| An array of numbers | Low, if you write into the shared memory without copying |
| A string | Medium: encode, allocate, copy, and the same on the way back |
| An object or an array of objects | High: it has to be flattened into bytes in an agreed format |
Hence the rule that governs the design of any Wasm integration:
Cross the boundary a few times with a lot of work, never many times with a little. One call that processes 200,000 items is excellent; 200,000 calls that process one each are a disaster, and they will be slower than doing it all in JavaScript.
One nuance so you do not walk away with an out-of-date picture: the host type references proposal and the integration with the garbage collector (WasmGC, already available in several browsers) reduce this friction for GC languages such as Java, Kotlin or Dart. But the linear memory mental model is still the one you need for C, C++ and Rust.
- The
.wat text format versus the .wasm binary
.wat text format versus the .wasm binaryWasm has two equivalent representations: the .wasm binary that gets downloaded, and a human-readable .wat text format, meant for debugging and learning.
;; add.wat — the "hello world" of WebAssembly
(module
;; Declares a function called "add" that takes two i32 and returns an i32
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a ;; pushes $a onto the stack
local.get $b ;; pushes $b onto the stack
i32.add) ;; pops both, adds them, leaves the result on the stack
;; Makes it visible from JavaScript under the name "add"
(export "add" (func $add))
)It reads from top to bottom as a stack machine: every instruction pops operands off the stack and leaves results. local.get $a pushes the first parameter; local.get $b pushes the second; i32.add pops both and pushes the sum, which ends up as the return value.
A slightly more realistic example, with memory:
(module
;; 1 page of memory = 64 KiB. It is exported so JavaScript can read it
(memory (export "memory") 1)
;; Adds up all the f64 values in an array starting at $ptr with $n elements
(func $sumArray (param $ptr i32) (param $n i32) (result f64)
(local $i i32)
(local $total f64)
(loop $loop
(br_if 1 (i32.ge_u (local.get $i) (local.get $n))) ;; if i >= n, exit
;; total += memory[ptr + i*8] (an f64 takes 8 bytes)
(local.set $total
(f64.add (local.get $total)
(f64.load (i32.add (local.get $ptr)
(i32.mul (local.get $i) (i32.const 8))))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)
)
(local.get $total)
)
(export "sumArray" (func $sumArray))
)Notice the level you are working at: the indices are multiplied by 8 by hand because an f64 takes eight bytes, and the loop is built out of explicit jumps. Nobody writes Wasm by hand for production; it is compiled from a high-level language. Looking at the .wat is useful for three things: understanding the model, debugging what your compiler generated, and checking the size of the result.
To convert between formats you use WABT (WebAssembly Binary Toolkit):
wat2wasm add.wat -o add.wasm # text → binary
wasm2wat add.wasm -o add.wat # binary → text (it can be decompiled!)
wasm-objdump -x add.wasm # inspect the sectionsThat wasm2wat is the proof that Wasm does not protect your code: anybody can recover a readable version of the module you serve.
- Loading and instantiating from JavaScript
There are four ways of loading a module, and one is clearly the best:
| Function | Input | Returns | When |
|---|---|---|---|
WebAssembly.instantiateStreaming(response, imports) |
A Response from fetch |
{ module, instance } |
The recommended one |
WebAssembly.instantiate(bytes, imports) |
ArrayBuffer |
{ module, instance } |
If you already have the bytes |
WebAssembly.compileStreaming(response) |
A Response |
Module |
Compile now, instantiate later |
new WebAssembly.Instance(module, imports) |
Module |
Instance |
Synchronous: only for small, already-compiled modules |
// js/wasm/load.js
export async function loadModule(url, imports = {}) {
// instantiateStreaming compiles WHILE it downloads: it does not wait for the last byte
const { instance, module } = await WebAssembly.instantiateStreaming(
fetch(url),
imports
);
return { exports: instance.exports, module };
}const { exports } = await loadModule('/wasm/add.wasm');
console.log(exports.add(3, 4)); // 7 ← it is called like an ordinary functioninstantiateStreaming is the correct way because it compiles in parallel with the download, instead of waiting to have everything. But it has a strict requirement that causes the most frequent error of all:
The server must send
Content-Type: application/wasm. If it does not, the browser throwsTypeError: Incorrect response MIME type.
Many static servers already do it; some do not. The fallback, just in case:
export async function loadModuleRobust(url, imports = {}) {
try {
return await WebAssembly.instantiateStreaming(fetch(url), imports);
} catch (error) {
console.warn('[wasm] Streaming failed, loading via ArrayBuffer:', error.message);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`); // the ok check from 07-02
const bytes = await response.arrayBuffer();
return WebAssembly.instantiate(bytes, imports);
}
}And checking availability, with the same progressive enhancement pattern from 07-06:
export const HAS_WASM = typeof WebAssembly === 'object'
&& typeof WebAssembly.instantiateStreaming === 'function';
- Crossing the boundary: exporting, importing and sharing memory
Exporting is what the module offers to JavaScript: functions, memory, tables and global variables. It all shows up in instance.exports.
Importing is what JavaScript offers to the module. It is passed as the second argument, grouped by namespaces:
const imports = {
env: {
// The module can call functions of ours
notify: (code) => console.log('[wasm] notice', code),
now: () => Date.now(),
// Or use a memory that we create
memory: new WebAssembly.Memory({ initial: 16, maximum: 256 }) // 64 KiB pages
}
};
const { instance } = await WebAssembly.instantiateStreaming(fetch('/wasm/plan.wasm'), imports);There is the answer to "how does Wasm touch the DOM?": it does not. You pass it a JavaScript function that can, and the module calls it. Everything Wasm can do with the outside world goes through the imports you give it, and that is exactly what makes it safe.
Sharing memory is the efficient way of moving data in bulk:
/** Copies a JavaScript array into Wasm's memory and calls the function. */
function computeOverArray(instance, values) {
const { memory, alloc, sumArray } = instance.exports;
const bytes = values.length * 8; // Float64Array: 8 bytes per number
const pointer = alloc(bytes);
// A VIEW over Wasm's memory: there is no copy of the buffer, only interpretation
const view = new Float64Array(memory.buffer, pointer, values.length);
view.set(values); // here the data really is copied
return sumArray(pointer, values.length);
}And a trap that takes hours to discover:
If the memory grows (because of a call to
memory.grow()or an internal allocation by the module), the previousArrayBufferbecomes detached and all your views stop working. Create the view just before using it, never store it in a long-lived variable.
// ✗ Dangerous: if the memory grows, this view becomes useless
const MEMORY = new Uint8Array(instance.exports.memory.buffer);
// ✓ Create the view at the moment of use
const view = () => new Uint8Array(instance.exports.memory.buffer);
- The real toolchains: Rust, C/C++ and AssemblyScript
In practice nobody writes .wat. You compile from a high-level language, and each one has its own toolchain.
| Tool | Language | Strong at | Learning curve | Typical size |
|---|---|---|---|---|
wasm-pack + wasm-bindgen |
Rust | The best JavaScript integration; it generates the glue automatically | High (learning Rust) | Small (tens of KB) |
| Emscripten | C / C++ | Porting existing code, including huge libraries | Medium | Medium to large |
| AssemblyScript | A subset of TypeScript | Getting started without learning another language | Low | Very small |
| TinyGo | Go | Reusing Go code | Medium | Medium |
| wasm-bindgen (on its own) | Rust | Fine-grained control of the bindings | High | — |
Rust with wasm-pack is the most complete option today. wasm-bindgen automatically generates the JavaScript code that translates strings, structs and objects, saving you all the manual memory management:
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn best_assignment(hours: &[f64], capacity: f64) -> f64 {
// Here you can use strings and structs: wasm-bindgen generates the glue!
hours.iter().filter(|h| **h <= capacity).sum()
}cargo install wasm-pack
wasm-pack build --target web # generates pkg/ with the .wasm and the binding .jsimport init, { best_assignment } from './pkg/planner.js';
await init(); // loads and instantiates the .wasm
console.log(best_assignment(new Float64Array([12, 8, 5]), 10)); // 13AssemblyScript is the best entry point for someone coming from JavaScript, because it is written with TypeScript syntax:
// planner.ts — AssemblyScript: it looks like TypeScript, it compiles to Wasm
export function weightedEffort(weights: Int32Array, hours: Float64Array): f64 {
let total: f64 = 0;
for (let i = 0; i < weights.length; i++) {
total += f64(weights[i]) * hours[i];
}
return total;
}npm install --save-dev assemblyscript
npx asinit .
npx asc planner.ts --target release --outFile build/planner.wasmWatch out for a common confusion: AssemblyScript is not TypeScript. It shares the syntax, but it has its own types (i32, f64, u8), it has no dynamic types and no full closures, and its garbage collector is different. It is a separate language wearing familiar clothes.
Emscripten compiles C and C++ and even emulates parts of the operating system (files, SDL, OpenGL), which makes it possible to port complete programs:
It is the tool that has brought things like ffmpeg and SQLite to the web.
- Real use cases
Wasm is not theoretical: there is important software running on it.
| Area | Examples | Why Wasm |
|---|---|---|
| Image and video editing | Figma, Photoshop on the web, ffmpeg.wasm | Millions of pixels per frame; computation dominates |
| Databases | SQLite compiled to Wasm (sql.js, wa-sqlite) |
A complete engine, in C, running in the browser |
| Cryptography | libsodium.js, hash implementations | Audited code you do not want to rewrite; constant time |
| Games and 3D | Unity, Unreal, in-house engines | Predictable performance at 60 fps, engines in C++ |
| CAD and engineering | AutoCAD web, simulators | Decades of irreplaceable C++ code |
| Scientific | Pyodide (the whole of Python in the browser), R, NumPy | Complete ecosystems with no server |
| Compilers and languages | Rust, Go and Zig playgrounds | Running the real compiler on the client |
| Outside the browser | Envoy, Fastly and Shopify plugins | A portable, fast sandbox for third-party code |
That last case is interesting: Wasm started in the browser, but its combination of portability and isolation is turning it into a plugin format for servers and platforms.
Notice the pattern they all share: intensive computation over a lot of data, or existing code that cannot be rewritten. None of them is an application of forms and lists.
- JavaScript versus Wasm: an honest comparison
| Aspect | JavaScript | WebAssembly |
|---|---|---|
| Speed at intensive computation | Good, with JIT | Better and more predictable (typically 1.2× to 3×) |
| Speed manipulating the DOM | Native | Worse: every operation crosses the boundary |
| Time to first execution | Immediate | Download + module compilation |
| Download size | Only your code | The module includes its runtime: Rust ~30-100 KB, Emscripten often more |
| Cost of calling a function | Zero | Low per call, high if you pass strings or objects |
| Debugging | Excellent: full DevTools | Improving (source maps), still uncomfortable |
| Learning curve | You already know it | Another language + its toolchain |
| Ecosystem | Huge | Small and specialized |
| Access to browser APIs | Total | Only what you import from JavaScript |
| Maintenance | One language | Two languages, two builds, two teams |
The three things most underestimated when evaluating Wasm:
- The size. A simple Rust module is tens of KB compressed; an Emscripten one with the standard library can be hundreds. If your computation takes 40 ms in JavaScript and the module weighs 300 KB, downloading it costs more than it saves.
- The cost of the boundary. If the module is called many times with little work, translating the data eats the gain and you end up slower.
- The human cost. Adding Rust to a JavaScript project means another build chain, other dependencies, other knowledge in the team and another point of failure in the deployment. It is an architectural decision, not a technical tweak.
When it really is worth it, summed up in three conditions that must hold at the same time:
- The work is pure, prolonged computation (tens of milliseconds or more).
- The boundary is crossed few times with a lot of data.
- Or the code already exists in C/C++/Rust and rewriting it would be absurd.
If any of them is missing, the right answer is JavaScript. And if the problem is that the interface freezes, there is a much cheaper alternative than Wasm: moving the computation into a Web Worker, which is still JavaScript but on another thread.
- WASI and the component model
Two pieces of Wasm's future worth knowing by name.
WASI (WebAssembly System Interface) is a standard set of APIs that gives Wasm modules controlled access to system capabilities —files, clock, network, environment variables— outside the browser. With WASI, a .wasm is a portable binary that runs identically on Linux, macOS and Windows, with explicit per-capability permissions. It is the foundation of platforms such as Wasmtime and Wasmer, and of edge plugins.
The component model tackles the problem we have been talking about all along: the boundary. It defines high-level types —strings, records, lists, variants— through an interface language (WIT), so that a component written in Rust can call another written in Python without anyone writing translation code by hand.
Neither of the two changes how you work in the browser today, which is why they are just mentioned here. But they explain where the technology is heading: from "a format for speeding up computation on the web" to "a universal, safe and portable format for running third-party code anywhere".
- The Taller Nómada case: heavy planning
Let us build an honest example: a real Nómada Tasks computation that is heavy, and measure whether Wasm is worth it.
The problem. Marta wants to know how to split the open tasks between Iván, Lucía and herself so that nobody goes over 40 hours a week (rule R7) while minimizing the imbalance in weighted effort. With 5 open tasks and 3 people there are 3⁵ = 243 combinations: trivial. But when planning a whole quarter, with 11 tasks and 3 people, that is 3¹¹ ≈ 177,000; and if you also explore the order within each person, you easily pass 200,000 combinations.
In JavaScript:
// js/planning/simulator.js — the JavaScript version, the reference
import { WEIGHTS } from '../util/format.js';
const TEAM = ['Marta', 'Iván', 'Lucía'];
const MAX_HOURS = 40; // R7
/**
* Tries every possible assignment and returns the one with the least imbalance.
* Complexity: 3^n. With n = 11, around 177,000 combinations.
*/
export function bestSplit(tasks) {
const n = tasks.length;
const combinations = 3 ** n;
let bestCost = Infinity;
let bestAssignment = null;
const hours = new Float64Array(3);
const effort = new Float64Array(3);
for (let c = 0; c < combinations; c += 1) {
hours.fill(0);
effort.fill(0);
// Each combination is encoded in base 3: digit i says who task i goes to
let rest = c;
let valid = true;
for (let i = 0; i < n; i += 1) {
const person = rest % 3;
rest = Math.floor(rest / 3);
hours[person] += tasks[i].estimatedHours;
if (hours[person] > MAX_HOURS) { valid = false; break; } // pruning by R7
effort[person] += WEIGHTS[tasks[i].priority] * tasks[i].estimatedHours;
}
if (!valid) continue;
// Cost = difference between whoever carries the most and the least effort
const cost = Math.max(...effort) - Math.min(...effort);
if (cost < bestCost) {
bestCost = cost;
bestAssignment = c;
}
}
return { cost: bestCost, code: bestAssignment, combinations };
}This code is a good candidate for Wasm because it meets the three conditions: it is pure computation, it is called once with all the data, and it works exclusively with numbers.
The AssemblyScript version, deliberately similar:
// wasm/planner.ts (AssemblyScript)
const MAX_HOURS: f64 = 40;
export function bestSplit(hoursPtr: usize, weightsPtr: usize, n: i32): f64 {
let combinations: i32 = 1;
for (let i = 0; i < n; i++) combinations *= 3;
let bestCost: f64 = f64.MAX_VALUE;
for (let c = 0; c < combinations; c++) {
let h0: f64 = 0, h1: f64 = 0, h2: f64 = 0;
let e0: f64 = 0, e1: f64 = 0, e2: f64 = 0;
let rest = c;
let valid = true;
for (let i = 0; i < n; i++) {
const person = rest % 3;
rest = rest / 3;
// Direct read from linear memory: 8 bytes per f64, 4 per i32
const hours = load<f64>(hoursPtr + i * 8);
const weight = f64(load<i32>(weightsPtr + i * 4));
if (person == 0) { h0 += hours; if (h0 > MAX_HOURS) { valid = false; break; } e0 += weight * hours; }
else if (person == 1) { h1 += hours; if (h1 > MAX_HOURS) { valid = false; break; } e1 += weight * hours; }
else { h2 += hours; if (h2 > MAX_HOURS) { valid = false; break; } e2 += weight * hours; }
}
if (!valid) continue;
const max = Math.max(e0, Math.max(e1, e2));
const min = Math.min(e0, Math.min(e1, e2));
if (max - min < bestCost) bestCost = max - min;
}
return bestCost;
}And the bridge from JavaScript:
// js/planning/wasm-bridge.js
let instance = null;
export async function initPlanner(url = '/wasm/planner.wasm') {
if (instance !== null) return instance; // instantiate ONCE
const { instance: created } = await WebAssembly.instantiateStreaming(fetch(url), {
env: { abort: () => { throw new Error('[wasm] abort'); } }
});
instance = created;
return instance;
}
export function bestSplitWasm(tasks) {
const { memory, __new, bestSplit } = instance.exports;
const n = tasks.length;
// Allocate and write both arrays into the linear memory
const hoursPtr = __new(n * 8, 0);
const weightsPtr = __new(n * 4, 0);
// Views created NOW: the memory may have grown while allocating
new Float64Array(memory.buffer, hoursPtr, n).set(tasks.map((t) => t.estimatedHours));
new Int32Array(memory.buffer, weightsPtr, n).set(tasks.map((t) => WEIGHTS[t.priority]));
return bestSplit(hoursPtr, weightsPtr, n); // ONE single crossing
}Look at what has been achieved: the boundary is crossed once to write the data and once to make the call. All the work of 177,000 iterations happens inside Wasm. That is the correct design.
- Measuring whether it is worth it
And now the only thing that settles the argument. Any claim about performance without measurement is an opinion.
// js/planning/compare.js
import { bestSplit } from './simulator.js';
import { initPlanner, bestSplitWasm } from './wasm-bridge.js';
/** Runs a function several times and returns the median, which is more robust than the mean. */
function measure(name, fn, runs = 7) {
const times = [];
fn(); // warm-up: let the JIT optimize
for (let i = 0; i < runs; i += 1) {
const start = performance.now();
fn();
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
console.log(`${name}: ${median.toFixed(1)} ms (min ${times[0].toFixed(1)})`);
return median;
}
export async function compare(tasks) {
const loadStart = performance.now();
await initPlanner();
const msLoad = performance.now() - loadStart;
const msJs = measure('JavaScript', () => bestSplit(tasks));
const msWasm = measure('WebAssembly', () => bestSplitWasm(tasks));
const savingPerCall = msJs - msWasm;
const callsToBreakEven = savingPerCall > 0 ? Math.ceil(msLoad / savingPerCall) : Infinity;
console.table({
'Module load (ms)': msLoad.toFixed(1),
'JavaScript (ms)': msJs.toFixed(1),
'WebAssembly (ms)': msWasm.toFixed(1),
'Speedup': `${(msJs / msWasm).toFixed(2)}×`,
'Calls to break even on the load': callsToBreakEven
});
return { msLoad, msJs, msWasm };
}A typical result on a modern laptop with 11 tasks:
| Measurement | Value |
|---|---|
| Module load and compilation | ~15 ms (+ 24 KB of download) |
| JavaScript | ~48 ms |
| WebAssembly | ~19 ms |
| Speedup | 2.5× |
| Calls needed to break even on the load | 1 |
The speedup is real. And now the honest question: is it worth it for Nómada Tasks?
| Question | Answer |
|---|---|
| Is it a heavy computation? | Yes, 177,000 combinations |
| Is it called often? | No: Marta plans once a quarter |
| Do 48 ms bother the user? | No. Below about 100 ms it is perceived as instant |
| How much does it cost to maintain? | One more language, one more build chain, one more deployment |
| Is there a cheaper alternative? | Yes: a Web Worker, which avoids the blocking without leaving JavaScript |
Verdict: it is not worth it. Saving 29 milliseconds on a quarterly operation does not justify adding AssemblyScript to the project. If the computation grew to 20 tasks (3²⁰ ≈ 3.5 billion combinations), not even Wasm would save the approach: you would need a better algorithm —dynamic programming or a heuristic— and that is the underlying lesson.
Before optimizing the technology, optimize the algorithm. And before optimizing anything, measure. A change from O(3ⁿ) to O(n log n) beats any constant factor of 2.5×.
That discipline —measure first, decide afterwards, on grounds of cost rather than enthusiasm— is exactly the subject of Module 9, and in particular of Measure Before Optimizing, where you will find the profiling tools that turn these homemade measurements into serious analysis.
Common Mistakes and Tips
- Believing that Wasm replaces JavaScript. They coexist, and Wasm cannot even touch the DOM without going through JavaScript.
- Using Wasm to speed up DOM manipulation. It will be slower: every operation crosses the boundary.
- Using Wasm to hide the code.
wasm2watdecompiles it. It is not protection. - Crossing the boundary inside a loop. 200,000 small calls are slower than doing it all in JavaScript. One call with 200,000 items is the right way.
- Serving the
.wasmwithoutContent-Type: application/wasm.instantiateStreamingfails with a baffling MIME error. - Keeping a long-lived typed view over the memory. If the memory grows, the
ArrayBufferis detached and the view stops working. Create it just before using it. - Forgetting to free memory. In C/C++/Rust without
wasm-bindgen, what you allocate has to be freed: there are leaks in Wasm too. - Instantiating the module on every call. Compiling costs. Instantiate once and reuse.
- Ignoring the download size. A 300 KB module to save 20 ms is a net loss.
- Measuring without a warm-up. JavaScript's first run is not JIT-optimized and skews the comparison in Wasm's favor.
- Measuring only once. Use the median of several runs.
- Adding Wasm with no maintenance plan. Another language, another build, another deployment, another skill set in the team.
- Tip: try a Web Worker first. If the problem is the interface freezing, a worker solves it without leaving JavaScript.
- Tip: start with AssemblyScript if you want to experiment. The syntax will feel familiar and you will see the model without learning Rust.
- Tip: look at the
.watyour compiler generates. It is the best way of understanding what is really going on. - Tip: you can set breakpoints in Wasm from DevTools. Debugging has improved a lot; with source maps you can even debug the original Rust.
- Tip: check availability (
typeof WebAssembly === 'object') and always keep the JavaScript route as a fallback.
Exercises
Exercise 1 — Loading and using a minimal module.
Write add.wat with two exported functions: add(a, b) that adds two i32, and factorial(n) that computes the factorial iteratively. Compile it with wat2wasm. Then write js/wasm/load.js with a loadModule(url) function that uses instantiateStreaming with an arrayBuffer fallback, checks that WebAssembly is available and throws a clear error if the server does not send the right MIME type. Verify that add(3, 4) gives 7 and factorial(10) gives 3,628,800.
Exercise 2 — A rigorous comparison.
Write compareImplementations({ name, js, wasm, inputs }) that runs both versions over several input sizes, with a warm-up, the median of seven repetitions and a check that both return the same result (an optimization that gives a different result is not an optimization). It must produce a table with console.table including, for each size: JS time, Wasm time, speedup and whether the results match. Add the input size from which Wasm starts to be worth it.
Exercise 3 — When NOT to use Wasm.
Without writing any Wasm, write a reasoned report —in the form of an evaluateWasm(profile) function returning a recommendation— that takes { msInJs, callsPerSession, moduleKb, workKind, hasNativeCode, teamKnowsRust } and returns { recommendation: 'yes' | 'no' | 'maybe', reasons: [...] }. It must apply the lesson's rules: rule it out if the work is DOM or I/O, rule it out if the JavaScript time is already imperceptible, work out how many sessions it takes to pay off the download, and weigh the human cost. Test it with the real Nómada Tasks profile and with that of an image editor.
Solutions
Solution 1
;; add.wat
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add)
(func $factorial (param $n i32) (result i32)
(local $result i32)
(local $i i32)
(local.set $result (i32.const 1))
(local.set $i (i32.const 2))
(block $end
(loop $loop
(br_if $end (i32.gt_s (local.get $i) (local.get $n))) ;; if i > n, exit
(local.set $result (i32.mul (local.get $result) (local.get $i)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)))
(local.get $result))
(export "add" (func $add))
(export "factorial" (func $factorial))
)// js/wasm/load.js
export const HAS_WASM = typeof WebAssembly === 'object'
&& typeof WebAssembly.instantiate === 'function';
export async function loadModule(url, imports = {}) {
if (!HAS_WASM) throw new Error('This browser does not support WebAssembly.');
// Fast path: compiles while downloading
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
const { instance, module } = await WebAssembly.instantiateStreaming(fetch(url), imports);
return { exports: instance.exports, module };
} catch (error) {
if (error.message.includes('MIME')) {
console.warn(`[wasm] The server does not send Content-Type: application/wasm for ${url}. ` +
'Falling back to the slow path; fix it on the server.');
} else {
console.warn('[wasm] instantiateStreaming failed:', error.message);
}
}
}
// Fallback: download the whole thing and compile afterwards
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} loading ${url}`); // 07-02
const bytes = await response.arrayBuffer();
const { instance, module } = await WebAssembly.instantiate(bytes, imports);
return { exports: instance.exports, module };
}const { exports } = await loadModule('/wasm/add.wasm');
console.log(exports.add(3, 4)); // 7
console.log(exports.factorial(10)); // 3628800The valuable detail is telling the MIME error apart from any other failure: the message says exactly what has to be fixed on the server instead of leaving an opaque TypeError. And notice the factorial: with i32 it overflows from 13 upwards, a reminder that Wasm's types are fixed-size and give no warning when they overflow, unlike JavaScript's Number.
Solution 2
// js/planning/compare.js
function measureMedian(fn, runs = 7) {
fn(); // JIT warm-up
const times = [];
for (let i = 0; i < runs; i += 1) {
const t0 = performance.now();
fn();
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
return times[Math.floor(times.length / 2)];
}
const nearlyEqual = (a, b, epsilon = 1e-9) =>
typeof a === 'number' && typeof b === 'number'
? Math.abs(a - b) < epsilon
: JSON.stringify(a) === JSON.stringify(b);
export function compareImplementations({ name, js, wasm, inputs }) {
const rows = {};
let threshold = null;
for (const input of inputs) {
const jsResult = js(input.data);
const wasmResult = wasm(input.data);
const match = nearlyEqual(jsResult, wasmResult);
if (!match) {
console.error(`❌ ${name} · ${input.label}: different results`,
{ js: jsResult, wasm: wasmResult });
}
const msJs = measureMedian(() => js(input.data));
const msWasm = measureMedian(() => wasm(input.data));
const speedup = msJs / msWasm;
if (threshold === null && speedup > 1.2) threshold = input.label;
rows[input.label] = {
'JS (ms)': msJs.toFixed(2),
'Wasm (ms)': msWasm.toFixed(2),
'Speedup': `${speedup.toFixed(2)}×`,
'Match': match ? '✅' : '❌'
};
}
console.table(rows);
console.log(threshold
? `Wasm starts to be worth it from: ${threshold}`
: 'Wasm is not worth it at any size tested.');
return rows;
}The check that the results match is the part nobody writes and the one most needed: a faster version that returns something different is not an optimization, it is a bug. And the nearlyEqual with an epsilon acknowledges a reality of floating-point numbers: JavaScript and Wasm can differ in the last bit when accumulating sums in a different order.
Solution 3
export function evaluateWasm({
msInJs, callsPerSession, moduleKb,
workKind, // 'compute' | 'dom' | 'network' | 'mixed'
hasNativeCode = false,
teamKnowsRust = false
}) {
const reasons = [];
// 1 · Immediate rule-outs
if (workKind === 'dom' || workKind === 'network') {
return { recommendation: 'no', reasons: [
`The work is of type "${workKind}": Wasm cannot speed it up and the cost of crossing the boundary would make it worse.`
]};
}
if (msInJs < 50) {
reasons.push(`${msInJs} ms in JavaScript is already perceived as instant (threshold ~100 ms).`);
}
// 2 · Paying off the download (at ~1 ms per KB on a middling connection)
const msDownload = moduleKb * 1;
const estimatedSaving = msInJs * 0.6; // we assume a 2.5× speedup
const callsToBreakEven = Math.ceil(msDownload / estimatedSaving);
reasons.push(
`The module (${moduleKb} KB ≈ ${msDownload} ms of download) pays for itself after ` +
`${callsToBreakEven} calls; the typical session makes ${callsPerSession}.`
);
// 3 · Human and reuse factors
if (hasNativeCode) reasons.push('C/C++/Rust code already exists: rewriting it would be the biggest cost.');
if (!teamKnowsRust && !hasNativeCode) {
reasons.push('The team does not know a compiled language: there is a learning and maintenance cost.');
}
reasons.push('Cheaper alternative: move the computation into a Web Worker (still JavaScript).');
// 4 · Verdict
if (hasNativeCode) return { recommendation: 'yes', reasons };
if (msInJs < 50 || callsPerSession < callsToBreakEven) {
return { recommendation: 'no', reasons };
}
if (msInJs > 200 && callsPerSession > callsToBreakEven * 5) {
return { recommendation: 'yes', reasons };
}
return { recommendation: 'maybe', reasons };
}// The real Nómada Tasks case
console.log(evaluateWasm({
msInJs: 48, callsPerSession: 1, moduleKb: 24,
workKind: 'compute', hasNativeCode: false, teamKnowsRust: false
}));
// { recommendation: 'no', reasons: [ '48 ms in JavaScript is already perceived as instant…', … ] }
// An image editor in the browser
console.log(evaluateWasm({
msInJs: 2400, callsPerSession: 300, moduleKb: 180,
workKind: 'compute', hasNativeCode: true, teamKnowsRust: true
}));
// { recommendation: 'yes', reasons: [ 'C/C++/Rust code already exists…', … ] }What is interesting about this function is not the code, but the fact that it forces you to write down the numbers before deciding. Most "let's use Wasm" decisions are made out of technological enthusiasm and do not survive filling in these six fields honestly.
Conclusion
You close Module 7 with the most counterintuitive lesson of all: the one that teaches a technology and ends up recommending against using it. You know that WebAssembly is a portable, safe binary format that runs in the same engine as JavaScript, and that it does not replace it: they coexist, they call each other, and Wasm cannot even touch the DOM unless you pass it a JavaScript function as an import. You know how to dismantle the four myths —it is not faster at everything, it does not hide the code (wasm2wat decompiles it), it does not access the file system in the browser, and it is not a language but a compilation target. And you know the two real reasons why it exists: predictable performance, without the JIT's speculative deoptimizations, and the ability to reuse decades of code in C, C++ and Rust.
You understand the execution model: module as class and instance as object, the linear memory that is literally an ArrayBuffer shared with JavaScript, the function table, and the only four numeric types. From that comes the idea that governs any integration: Wasm only understands numbers, strings and objects have to be serialized into bytes, and therefore you must cross the boundary a few times with a lot of work, never the other way round. You know how to read a .wat as a stack machine, how to load a module with instantiateStreaming —with its Content-Type: application/wasm requirement and its arrayBuffer fallback—, how to export and import, and how to share memory with typed views created just before using them, because if the memory grows the previous buffer is detached.
You know the real toolchains —wasm-pack with wasm-bindgen for Rust, Emscripten for C and C++, AssemblyScript as the entry point from TypeScript—, the cases where Wasm has won beyond argument (Figma, ffmpeg.wasm, SQLite, Pyodide, game engines, CAD) and the honest comparison almost nobody makes: the download size, the cost of the boundary and the human cost of maintaining two languages and two build chains. And above all, you have done the complete exercise with Taller Nómada's planning: a real simulation of 177,000 combinations, a JavaScript version, an AssemblyScript one, a measurement with a warm-up and a median… and a reasoned verdict of it is not worth it, because saving 29 milliseconds on a quarterly operation does not justify adding a language to the project, and because when the problem really grows the answer is not to change technology but to change the algorithm.
That brings Module 7 to an end. Nómada Tasks has covered a complete journey: it remembers between reloads with the Web Storage API and its local repository; it talks to a server through fetch, always checking response.ok; it survives the real network with ApiError, AbortController, timeouts, retries with exponential backoff and a four-state interface; it syncs live with a BoardChannel over WebSockets that reconnects on its own, beats a heartbeat and queues what is pending; it works offline and installed thanks to a service worker with its precached app shell and its per-resource-type strategies; and it behaves like a well-crafted application, with linkable filters, a theme that respects the system, animations that heed prefers-reduced-motion and proper formats with Intl. The js/data/ layer that started out empty now has four modules, and the model from Modules 1 to 5 has not changed a single line in the whole process.
And that is exactly where the problem that opens the next module lies. The application now does an enormous amount: six layers, fourteen modules, two data sources, a real-time channel, a caching proxy, business rules, interface states, optimistic reverts and sync queues. Each of those pieces can break the others, and no check happens on its own. Right now, the only way of knowing whether something still works is to open it and try it by hand; and when it fails, the only way of finding out why is to litter the code with console.log. That does not scale: there comes a point where changing one line is frightening. What is needed is to debug with method rather than by intuition, to keep the code consistent with automated tools, and above all to automate the tests so the machine checks in seconds what you cannot check in an afternoon. That is Module 8: Testing and Debugging, which starts with Debugging JavaScript —and where you will find that the clean boundary between model and view, the one you have been maintaining for six modules, was from the start what was going to make it possible to test everything without opening a browser.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
