Layout off the main thread

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.

When you need a worker. Layouts under ~200 words often finish in a few milliseconds on the main thread. For dashboards that re-layout on every slider move, clouds with thousands of words, or pages where you must keep a loading spinner / chart animation alive — move layout to a worker.

How the pieces connect:

  1. Main thread — your UI code calls layoutWorker.layout(items, options). That returns a Promise and immediately yields control back to the browser.
  2. 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.
  3. Worker thread — the worker script imports the same layout() function, awaits WASM, and posts back either { type: "result", id, placements } or { type: "error", id, message }.
  4. Back on the main thread — the helper matches 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.

Step by step

Step 1

Create a layout worker on the main thread

createLayoutWorker() spawns a module worker unless you pass your own Worker instance. One layout worker is usually enough — reuse it across many layout calls.

app.js — main thread
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 });
Step 2

Request layout — same options as direct 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.

app.js — layout call
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.

app.js — concurrent requests
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.
Step 3

Render on the main thread, terminate when done

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.

app.js — render + cleanup
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();
Step 4

What the worker script does

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.

worker.js — simplified
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:

DirectionPayloadNotes
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).
Step 5

How the layout worker routes replies

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.

layout-worker.js — request routing (excerpt)
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 });
  });
}

Try it yourself

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.

live worker layout

Press Esc or click Exit fullscreen to return to the demo card.

idle

The status line tags results with (off-thread) so you can tell worker timing apart from main-thread layout at a glance.