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
Should this loop move to a worker? A decision tree. Start by measuring the loop wall time. If the loop reads or writes the DOM on every iteration, split the computation from the DOM writes first and re-measure. Otherwise: under 8 milliseconds, keep it on the main thread because the message overhead exceeds the win; between 8 and 50 milliseconds, chunk it on the main thread and migrate only if it runs on input; over 50 milliseconds, move it into a worker with chunked pull-based delivery. Measure the loop wall time Reads or writes the DOM inside the loop? yes Split compute from the DOM writes then re-measure no Wall time under 8 ms? yes Keep it on the main thread the overhead exceeds the win no Wall time over 50 ms? no Chunk on the main thread migrate only if input-driven yes Move it into a worker chunked and pull-based
The DOM branch comes first: a loop that measures or mutates nodes per iteration is not a migration candidate at all until the computation is separated from the writes. Only then do the 8 ms and 50 ms thresholds decide anything.

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.

The pull-based chunk protocol, message by message A sequence diagram with two lifelines. The main thread sends INIT with the whole dataset, then NEXT with a start index and a limit of 4000. The worker runs scoreRow four thousand times in three to five milliseconds and replies with a BATCH message. The main thread paints that batch inside requestAnimationFrame and only then sends the following NEXT, so exactly one request is ever in flight. Sixty-three round trips later the worker replies with done set to true and the main thread terminates it. MAIN THREAD WORKER INIT — the whole dataset, cloned once NEXT { start: 0, limit: 4000 } scoreRow × 4,000 = 3-5 ms BATCH { start, results, done } rAF then onBatch() paints backpressure window exactly one NEXT in flight NEXT { start: 4000, limit: 4000 } scoreRow × 4,000 = 3-5 ms BATCH { done: false } rAF then onBatch() paints 61 more NEXT / BATCH round trips NEXT { start: 248000 } BATCH { done: true } — then worker.terminate()
The worker never decides when to run. Every 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
Main-thread cost of handing 10 MB to the worker Horizontal bars on a 0 to 40 millisecond scale. Structured clone costs about 12 milliseconds and the main-thread copy stays valid. Transferring the ArrayBuffer costs under 0.05 milliseconds, a constant-time pointer swap, and detaches the main-thread view to zero bytes. SharedArrayBuffer copies nothing at all but requires cross-origin isolation headers. A JSON round trip costs about 35 milliseconds because the payload is materialised as a string twice. Cost of handing a 10 MB payload to the worker main-thread time per hand-off — shorter is better Structured clone main-thread copy stays valid ~12 ms — about 1.2 ms per MB Transfer the ArrayBuffer main-thread view detached to 0 B under 0.05 ms — an O(1) pointer swap SharedArrayBuffer one buffer, both threads read it no copy at all — but needs COOP / COEP JSON round trip two full string materialisations ~35 ms 0 10 20 30 40 ms Mid-range laptop, 10 MB of scored rows. Only the clone and the JSON bars are paid on the main thread every single message.
The two fast options are not interchangeable: transferring detaches your only reference, and 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.
COOP / COEP required for SharedArrayBuffer

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.

Captured dependencies do not cross the realm boundary Three panels. In the main-thread realm the loop body reaches out to a DOM node, a module-level cache and a closure variable. In the worker realm the identical source has those three links severed: there is no document, the module is a fresh second instance, and the closure never existed. The refactored panel below shows the same function taking the DOM value, the cache and the closure value as explicit parameters, so it returns identical results on either thread. MAIN-THREAD REALM DOM node module cache closure var the loop body reads 3 values it never took works, and hides its real inputs WORKER REALM — same source no document 2nd instance no closure the loop body throws, or quietly differs ReferenceError if you are lucky; wrong numbers if you are not REFACTORED — one module, imported by both threads DOM value → parameter cache → parameter closure value → parameter scoreRow(row, domValue, cache, locale) pure — no realm-specific reads left in it ScoredRow[] identical on both threads
Extraction is the defence, not discipline. Once every input is a declared parameter, the version that reached into a closure stops compiling — the failure moves from production data corruption to a type error on your machine.

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
The migration is only real if the long task is gone

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.

The same workload, before and after the migration Two timelines on the same 0 to 480 millisecond scale with 16.7 millisecond frame boundaries marked. Before, the main thread carries one unbroken 380 millisecond task that swallows 23 consecutive frame boundaries. After, the main-thread track holds only narrow postMessage and paint slivers on each frame boundary, while a parallel worker track carries 63 short chunk blocks of 3 to 5 milliseconds each. BEFORE — 250,000 rows scored inline main thread one unbroken 380 ms task 23 frame boundaries walked straight past — nothing paints, no input is handled AFTER — the same rows pulled through a worker, 4,000 at a time main thread one postMessage plus one rAF paint per frame — under 2 ms of the 16.7 ms budget worker 63 chunk tasks of 3-5 ms — never on the frame budget at all 0 ms 160 ms 320 ms 480 ms long tasks 1 → 0 · wall time 380 ms → 420 ms · INP unchanged
The bottom row is the whole point: total elapsed time got worse by 40 ms, and every frame in the window still painted. Wall time is not the metric the migration is optimising.

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.

Frequently Asked Questions

When is a loop too small to be worth moving into a Web Worker?
When the loop finishes in under roughly 8 ms. A round trip costs one structured clone of the input, one of the output, and two task-queue hops — typically 0.3-1 ms of fixed overhead plus about 1.2 ms per megabyte cloned. Below 8 ms of loop time you are paying that overhead to remove a task that never dropped a frame in the first place. Measure with performance.now() first and migrate only loops that show up as long tasks — over 50 ms — in the Chrome Performance panel.
My loop produced identical results on the main thread but returns wrong values inside the worker. What changed?
Almost always a captured dependency that did not cross the boundary. A loop body that reads a DOM node, a module-level cache, document, window, location, or a closure variable defined in the calling scope compiles fine inside a worker but resolves to something different — or throws ReferenceError — because the worker has its own global scope and its own module instances. Extracting the body into a pure function that takes every input as an argument, and importing that same module from both threads, turns this class of bug into a compile-time error instead of a silent data corruption.

See also