Packing a large word cloud can keep the CPU busy for tens of milliseconds — long enough to stutter animations and miss input events. The fix is familiar: move the heavy work off the main thread. Here we'll run the same layout inside a Web Worker, using the small layout-worker helper that ships with the library.
How the pieces connect:
layoutWorker.layout(items, options).
That returns a Promise and immediately yields control back to the browser.postMessage — the helper sends a structured-cloneable
message { type: "layout", id, items, options } to the worker. Each call
gets a unique id so replies can arrive out of order.layout() function, awaits WASM, and posts back either
{ type: "result", id, placements } or { type: "error", id, message }.id to the
pending Promise and resolves or rejects it. From there you draw on canvas exactly as
you would after a direct layout() call.
createLayoutWorker() spawns a module worker unless you pass your own
Worker instance. One layout worker is usually enough — reuse it across
many layout calls.
import { createLayoutWorker } from "./js/layout-worker.js"; // Spawns: new Worker(new URL("./worker.js", import.meta.url), { type: "module" }) const layoutWorker = createLayoutWorker(); // Optional: bring your own worker (custom URL, shared worker pool, etc.) // const layoutWorker = createLayoutWorker({ worker: myWorker });
layout()
The worker API accepts the same options as calling layout() directly.
Items and options are copied via the structured-clone algorithm (plain objects and arrays
only — no functions or DOM nodes). The returned placements are plain objects too.
const items = [ { text: "WebAssembly", weight: 100 }, { text: "worker", weight: 60 }, // … ]; const placements = await layoutWorker.layout(items, { mode: "pretty", width: 960, height: 600, minFontSize: 14, maxFontSize: 88, padding: 2, }); // placements shape is identical to main-thread layout() // → [{ text, x, y, rotation, fontSize }, ...]
Concurrent calls. You can fire multiple layoutWorker.layout() calls
without awaiting each one first. The helper assigns incrementing IDs; whichever layout
finishes first resolves its Promise first. Useful for prefetching alternate datasets or
A/B layouts.
const [a, b] = await Promise.all([ layoutWorker.layout(datasetA, { mode: "fast", width: 800, height: 500 }), layoutWorker.layout(datasetB, { mode: "fast", width: 800, height: 500 }), ]); // a and b resolve independently on the worker — IDs keep them straight.
Drawing still happens on the main thread — workers cannot touch the DOM or canvas directly.
After you paint, call layoutWorker.terminate() to free the worker thread (and reject
any in-flight requests). For long-lived apps, keep the layout worker alive and reuse it.
import { drawWordCloud } from "./render.js"; // your canvas draw helper const ctx = document.querySelector("canvas").getContext("2d"); drawWordCloud(ctx, placements); // When navigating away or shutting down: layoutWorker.terminate();
You rarely need to touch this file — createLayoutWorker() wires it up for you.
Still, the message protocol is worth knowing if you want to fold layout into an existing
worker or add your own logging.
import { layout } from "./index.js"; self.addEventListener("message", async (event) => { const msg = event.data; if (!msg || msg.type !== "layout") return; const { id, items, options } = msg; try { const placements = await layout(items, options); // WASM init is cached inside layout() — first message pays download cost. self.postMessage({ type: "result", id, placements }); } catch (err) { self.postMessage({ type: "error", id, message: err.message ?? String(err), }); } });
Message protocol summary:
| Direction | Payload | Notes |
|---|---|---|
| main → worker | { type: "layout", id, items, options } |
id is a positive integer assigned by the layout worker. |
| worker → main | { type: "result", id, placements } |
Resolves the matching Promise with placements. |
| worker → main | { type: "error", id, message } |
Rejects the matching Promise with Error(message). |
The layout worker is a thin Promise router: a Map tracks in-flight requests by id.
If the worker crashes outright (failed import, uncaught exception), every pending Promise
is rejected so nothing hangs forever.
const pending = new Map(); let nextId = 1; worker.addEventListener("message", (event) => { const msg = event.data; const entry = pending.get(msg.id); if (!entry) return; pending.delete(msg.id); if (msg.type === "result") entry.resolve(msg.placements); else entry.reject(new Error(msg.message)); }); function layout(items, options) { const id = nextId++; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); worker.postMessage({ type: "layout", id, items, options }); }); }
The spinner in the panel header is a CSS animation on the main thread. If layout blocked that thread, it would freeze during large runs — watch it keep spinning while thousands of words pack in the background.
The status line tags results with (off-thread) so you can tell worker
timing apart from main-thread layout at a glance.