Migrating Synchronous Loops to Web Workers Safely
Taking a for loop that already works and running it on another thread without changing a single output value — the mechanical refactor, the four traps that silently corrupt results, and the measurement that proves it was worth doing.
This page is the refactoring companion to CSV & JSON Transform Pipelines, part of the High-Performance Computation Patterns reference. That guide builds a streaming pipeline from scratch; this one starts from code you already shipped. The distinction matters, because an existing loop carries hidden dependencies — closures, module state, DOM reads — that a greenfield pipeline never has, and those dependencies are what make a migration fail quietly rather than loudly.
Decide Whether the Loop Should Move at All
Offloading is not free, and the overhead has a floor that does not shrink with your data. Every migrated loop pays for one clone of the input, one clone of the output, and two task-queue hops between threads. On a mid-range laptop that is roughly 0.3-1 ms of fixed cost plus about 1.2 ms per megabyte of cloned payload. A loop that runs in 4 ms comes back slower, and now it is asynchronous too.
Get the number before you refactor:
// Paste into the DevTools console on the page that janks.
const t0 = performance.now();
for (let i = 0; i < dataset.length; i++) {
transformRow(dataset[i]);
}
console.log(`sync loop: ${(performance.now() - t0).toFixed(1)} ms over ${dataset.length} rows`);
Then record a Performance trace with Screenshots and Memory enabled, run the interaction that triggers the loop, and filter the flame chart for long tasks. The call stack under the red-cornered task tells you which loop actually owns the stall — frequently not the one you suspected, because a cheap-looking map chain over 200k rows costs more than the parse that produced them.
| Baseline signal | Where to read it | Migration verdict |
|---|---|---|
| Loop wall time under 8 ms | performance.now() delta |
Do not migrate — overhead exceeds the win |
| Loop wall time 8-50 ms | performance.now() delta |
Migrate only if it runs during an animation or on input |
| Task over 50 ms | Performance panel, long-task marker | Migrate — this is a dropped-frame source |
| Task over 50 ms plus heap growth per run | Memory track sawtooth | Migrate and chunk, so peak memory stays flat |
| Loop touches the DOM every iteration | Flame chart shows layout/recalc | Do not migrate as-is — separate compute from writes first |
That last row is the one people skip. A worker has no DOM, so a loop that reads element.offsetWidth or writes node.textContent per iteration cannot move until the measurement and the mutation are separated from the computation. Split it first, migrate second.
Minimal Reproducible Example
The smallest complete migration is three files: a pure transform imported by both threads, a worker that iterates it, and a main-thread driver that pulls batches.
1. The transform, extracted verbatim from the loop body. Nothing here touches document, window, or module-level mutable state — that is the entire discipline.
// score.ts — imported by the main thread AND the worker
export interface RawRow {
id: number;
amount: number;
currency: 'USD' | 'EUR' | 'GBP';
ts: number;
}
export interface ScoredRow {
id: number;
usd: number;
flagged: boolean;
}
const RATES: Readonly<Record<RawRow['currency'], number>> = { USD: 1, EUR: 1.08, GBP: 1.27 };
/** Pure: same input, same output, on either thread. */
export function scoreRow(row: RawRow): ScoredRow {
const usd = row.amount * RATES[row.currency];
return { id: row.id, usd, flagged: usd > 10_000 };
}
2. The worker. It owns the dataset and hands back one slice per request — it never decides on its own when to run.
// score.worker.js — module worker, plain JS so `type: 'module'` semantics are explicit
import { scoreRow } from './score.js';
/** @type {import('./score.js').RawRow[]} */
let rows = [];
self.onmessage = ({ data }) => {
try {
if (data.type === 'INIT') {
rows = data.rows;
return;
}
if (data.type === 'NEXT') {
const end = Math.min(data.start + data.limit, rows.length);
const results = new Array(end - data.start);
for (let i = data.start; i < end; i++) {
results[i - data.start] = scoreRow(rows[i]);
}
self.postMessage({ type: 'BATCH', start: data.start, results, done: end >= rows.length });
}
} catch (err) {
// A throw here must not become an unhandled rejection the main thread never sees.
self.postMessage({
type: 'ERROR',
start: data?.start ?? 0,
name: err.name,
message: err.message,
stack: err.stack ?? '',
});
}
};
// Last-resort net for anything the try/catch cannot reach (import failures, syntax errors
// in dynamically imported code). preventDefault() stops the browser also firing
// `worker.onerror` on the main thread, so only call it after the report is posted.
self.addEventListener('error', (event) => {
self.postMessage({ type: 'ERROR', start: -1, name: 'WorkerError', message: event.message, stack: '' });
event.preventDefault();
});
3. The driver. Typed message union, one request in flight at a time, explicit termination.
// offload.ts
import type { RawRow, ScoredRow } from './score.js';
type ToWorker =
| { type: 'INIT'; rows: RawRow[] }
| { type: 'NEXT'; start: number; limit: number };
type FromWorker =
| { type: 'BATCH'; start: number; results: ScoredRow[]; done: boolean }
| { type: 'ERROR'; start: number; name: string; message: string; stack: string };
const CHUNK_SIZE = 4_000; // tuned to a 3-5 ms worker execution window — see the performance note
export function scoreOffThread(
rows: RawRow[],
onBatch: (results: ScoredRow[], start: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./score.worker.js', import.meta.url), { type: 'module' });
let cursor = 0;
const fail = (err: Error) => { worker.terminate(); reject(err); };
const pull = () => worker.postMessage({ type: 'NEXT', start: cursor, limit: CHUNK_SIZE } satisfies ToWorker);
worker.onmessage = ({ data }: MessageEvent<FromWorker>) => {
if (data.type === 'ERROR') {
const err = new Error(`${data.name}: ${data.message} (rows ${data.start}+)`);
err.stack = data.stack || err.stack;
return fail(err);
}
cursor = data.start + data.results.length;
// Paint on a frame boundary; never write to the DOM straight out of onmessage.
requestAnimationFrame(() => onBatch(data.results, data.start));
if (data.done) { worker.terminate(); resolve(); } else { pull(); }
};
worker.onerror = (event) => fail(new Error(`Worker crashed: ${event.message}`));
worker.onmessageerror = () => fail(new Error('Message could not be deserialized'));
worker.postMessage({ type: 'INIT', rows } satisfies ToWorker);
pull();
});
}
Call site changes from one line to one await, and the results are byte-identical because both threads run the same scoreRow.
NEXT is issued from inside onmessage, after the previous batch has been handed to requestAnimationFrame — so a slow renderer throttles the producer instead of filling the message queue.Line-by-Line Walkthrough
new Worker(new URL('./score.worker.js', import.meta.url), { type: 'module' }) — the new URL(..., import.meta.url) form is what lets Vite, webpack and Rollup statically discover the worker file and emit it as a separate chunk with correctly rewritten imports. A bare string path survives dev and breaks in the production bundle.
worker.postMessage({ type: 'INIT', rows }) before the first pull() — message ordering on a port is guaranteed, so the worker is certain to have the data before it handles NEXT. No handshake message is needed.
cursor = data.start + data.results.length — the cursor advances from what came back, not from what was requested. If the worker ever returns a short batch, the next request still starts at the right index instead of silently skipping rows.
requestAnimationFrame(() => onBatch(...)) — onmessage runs at an arbitrary point in the task queue, often mid-frame. Writing to the DOM there causes forced synchronous layout; deferring to the next frame batches the write with the browser’s own rendering work.
pull() inside onmessage, never in a loop — this is the backpressure. Exactly one request exists at any moment, so the worker’s message queue cannot grow, and a slow renderer throttles the producer automatically instead of accumulating unpainted batches in memory.
satisfies ToWorker — checks the literal against the union without widening its type, so a typo in the discriminant fails at compile time on the sending side rather than falling through an if chain at runtime on the receiving side.
worker.terminate() on every exit path — resolve, reject, and error. A worker left running holds its heap and its copy of rows until the page unloads.
How the Data Crosses the Boundary
The example clones rows once at INIT. That is the right default for object arrays under a few tens of thousands of rows, and the wrong one for large numeric data, where you want the ownership handoff described in Transferable Objects & Zero-Copy.
// Zero-copy handoff: only a pointer changes hands, cost is O(1) in payload size.
const amounts = new Float64Array(250_000);
worker.postMessage({ type: 'INIT_COLUMNS', amounts }, [amounts.buffer]);
console.log(amounts.byteLength); // 0 — the main-thread view is now detached
| Strategy | Cost profile | Concurrency | Fits this migration when |
|---|---|---|---|
| Structured clone | ~1.2 ms/MB, brief main-thread block | Sequential | Rows are heterogeneous objects, under ~20k per message |
Transferable ArrayBuffer |
O(1) pointer swap, under 0.05 ms | Sequential | The loop reads numeric columns you can pack into typed arrays |
SharedArrayBuffer + Atomics |
No copy, needs COOP/COEP and explicit ordering | True concurrent | Several workers must read the same rows at once |
| JSON string round trip | ~2-5 ms/MB parse plus stringify | Sequential | Only as a cross-context fallback — never for a same-origin worker |
SharedArrayBuffer costs nothing at run time but buys you a server-header requirement. For a straight loop migration, the transfer is almost always the right trade.SharedArrayBuffer is only available when the document is cross-origin isolated — the server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without both headers it is undefined in the page and in the worker, and enabling them can break third-party embeds that do not set crossorigin. For a straight loop migration, a transferred ArrayBuffer gives you the same zero-copy cost with no header changes.
Gotchas & Edge Cases
1. Captured dependencies do not cross the boundary
The loop body you are moving may look self-contained and not be. Anything it reads from an enclosing closure, a module-level cache, a singleton, document, navigator.language, or a global configuration object either throws ReferenceError inside the worker or — much worse — resolves to a different value, because module instances are per-realm. A locale-aware formatter initialised at module load on the main thread becomes a second, differently-configured instance in the worker.
The extraction step in the example is the defence: if every input arrives as a function argument, TypeScript refuses to compile the version that reached into a closure. Migrate the loop body into score.ts first, confirm the main thread still passes its tests using that module, and only then point the worker at it.
2. A transferred buffer is detached, not copied
After postMessage(payload, [payload.buffer]), the main-thread view has byteLength === 0 and every typed-array view over that buffer throws TypeError: Cannot perform Construct on a detached ArrayBuffer on access. Retry logic is the usual victim: the failure handler tries to re-send the chunk it just gave away. If you need to retry, keep the transfer one-directional and have the worker transfer the buffer back as part of its response, so ownership ping-pongs deterministically.
3. Accumulators and ordering break when chunking
A loop with a running total, a reduce, a dedupe Set, or an index that depends on previous iterations is not chunk-safe by default. Two fixes work: keep the accumulator in the worker across NEXT messages (it lives in worker module scope, so it persists between calls), or make the transform associative and merge partial results on the main thread. Never let more than one request be in flight if the accumulator is order-dependent — the pull protocol above enforces that structurally.
4. Silent failure has three separate channels
worker.onerror catches uncaught exceptions in worker scope. worker.onmessageerror fires when a message arrives that cannot be deserialized — a payload containing a function or a DOM node. And a promise rejected inside the worker with no handler fires unhandledrejection in the worker’s scope and reaches the main thread through neither. Attach all three, and post a structured { name, message, stack } envelope rather than the Error object itself, since stack preservation across the clone is engine-dependent. The full treatment is in Fixing Uncaught Exceptions in Dedicated Workers.
Content Security Policy is the fourth silent failure: a worker-src directive that omits blob: or your origin makes the constructor throw synchronously. Feature-detect and keep the original synchronous loop as the fallback path — it still works, it just janks.
Performance Note
Size chunks by time, not row count. Target a worker execution window of 3-5 ms per NEXT, which keeps the full request-process-respond round trip inside one 16.7 ms display frame with room for garbage collection and the paint. Measure your per-row cost once and divide: a transform costing 1 µs per row gives 3,000-5,000 rows per chunk; a transform costing 20 µs per row gives 150-250.
| Chunk size | Message overhead | Peak memory | Round trip vs frame budget |
|---|---|---|---|
| Under 500 rows | Dominates — thousands of round trips | Minimal | Well under budget, throughput-limited |
| 3,000-5,000 rows | ~5% of total time | Moderate, flat | 3-5 ms work inside a 16.7 ms frame |
| Over 20,000 rows | Negligible | High — a large batch cloned per message | Overruns the frame, jank moves to the clone |
Moving a loop can move the stall instead of removing it — a 40 ms clone of the result batch is still a 40 ms main-thread task. Confirm the outcome with a PerformanceObserver on longtask before and after, and check the clone cost separately using the performance.now() harness in Measuring Structured Clone Cost with performance.now().
// Ship this alongside the migration and watch it in production, not just locally.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`long task ${entry.duration.toFixed(1)} ms`, entry.name);
}
}).observe({ type: 'longtask', buffered: true });
A successful migration of a 250,000-row scoring loop looks like this: one 380 ms long task before, zero long tasks after, total elapsed wall time slightly higher (around 420 ms, because of the clone and 63 round trips), and Interaction to Next Paint unaffected by the work. Wall time getting marginally worse while responsiveness gets dramatically better is the expected shape of the result — you did not make the computation faster, you made it stop owning the frame.
If the loop is one of several competing for CPU, a single worker becomes the new bottleneck; sizing a set of them is covered in Worker Pool Management.