Skip to content

    Frontend System Design

    Web Workers, Worklets and Off-Main-Thread Architecture

    Learn when a Web Worker helps, what postMessage really costs, how transferables and worker pools behave, and where worklets and OffscreenCanvas fit.

    By Published 13 min read

    • frontend-system-design
    • web-workers
    • off-main-thread
    • structured-clone
    Share

    Standard lessonintermediateTypeScript labFrontend System Design

    Before this: The Event Loop and the Main Thread

    What you'll learn: By the end of this lesson you will be able to explain what a Web Worker may and may not touch, describe what postMessage does to your data (structured clone) and when a transfer list removes the copy, design the message boundary and a small pool with queueing and cancellation, choose between a worker, a worklet, OffscreenCanvas and chunking, and say honestly what offloading buys: less risk for the main thread, not less total work.

    Picture a restaurant where one head chef takes orders, plates dishes and answers the door. A second cook in a back kitchen sounds like a fix, but the two cannot share a counter: they pass paper tickets through a hatch. A short ticket is cheap. A ticket that says "here is the whole pantry inventory" is not, unless you hand over the entire box instead of copying its contents.

    A Web Worker is that back kitchen. The Event Loop and the Main Thread listed workers as one remedy for a busy main thread. This lesson covers what the hatch costs, how to design what goes through it, and what to do when there are more tickets than cooks.

    The core idea

    1. A worker is another thread with its own event loop and no DOM. Page and worker share nothing by default. The only link is messages.
    2. A message is a copy, unless you transfer it. The sender pays to serialise and the receiver to rebuild, each on its own thread. A transfer moves ownership of a buffer instead.
    3. Offloading is a risk decision. It keeps the main thread available, and it adds messages, start-up and state on two sides. Total work does not shrink.

    What a worker is, as the standards describe it

    The HTML Standard says workers run scripts in the background, independently of the user interface scripts, so long work need not yield to keep the page responsive. It also says workers are "relatively heavy-weight" and "not intended to be used in large numbers" (launching one per pixel of a four megapixel image would be inappropriate), and expects them to be long-lived, with a high start-up cost and a high per-instance memory cost.

    MDN names three kinds. A dedicated worker is reachable only from the script that created it. A shared worker can be reached from several windows, frames or workers through a port. Service workers sit between page and network and are a separate topic.

    Inside a worker the global is self, not window, and you cannot touch the DOM or the parent page. You can run any JavaScript, use fetch(), WebSockets and IndexedDB, call setTimeout, and start same-origin sub-workers. MDN says Worker creates real OS-level threads, and that because the communication points are controlled and there is no DOM, it is hard to cause classic concurrency bugs.

    A worker has its own event loop. The standard says its task queues only have events, callbacks and networking activity as tasks, and a posted message arrives as a task on the receiver's loop: handled in a later turn, never in the middle of running code.

    Illustration 1: a minimal round trip

    // main.js (browser code, shown for shape; not run in this lesson)
    const worker = new Worker(new URL("./sum-worker.js", import.meta.url), { type: "module" });
    worker.onmessage = (event) => console.log("sum:", event.data.sum);
    worker.postMessage({ id: 1, numbers: [1, 2, 3, 4] });
    // sum-worker.js
    self.onmessage = (event) => {
      const { id, numbers } = event.data;
      self.postMessage({ id, sum: numbers.reduce((a, b) => a + b, 0) });
    };

    The reply carries the request's id. Keep that habit: the id is how you match replies, ignore stale ones and cancel.

    Figure 1. One page, a small pool of workers

    The page posts jobs to a queue; idle workers take the next job and reply with a messagePage code to Job queue (postMessage); Job queue to Worker 1 (next job); Job queue to Worker 2 (next job); Worker 2 to Page code (results)PAGE MAIN THREADWORKER THREADSPage code: DOM, eventsPage codeDOM, eventsJob queue: first in, first outJob queuefirst in, firstoutWorker 1: own loop, no DOMWorker 1own loop, no DOMWorker 2: own loop, no DOMWorker 2own loop, no DOMpostMessagenext jobnext jobresults

    How to read this: the queue is code you write on the page side. Each worker handles one message at a time on its own thread, and every result comes back as a message the page handles as a task.

    What postMessage does to your data

    By default postMessage() runs the data through the structured clone algorithm. MDN: data passed between page and worker is copied, not shared, "except for certain objects that can be explicitly shared".

    From MDN's description: primitives except symbol, arrays, Map, Set, Date, ArrayBuffer, typed arrays and plain objects survive, and a map of visited references lets cycles terminate. Functions and DOM nodes throw DataCloneError. The prototype chain is not walked, and getters, setters and property descriptors are not duplicated, so a class instance arrives as a plain object without its methods. Two checks in Node.js v26.12.1 (whose structuredClone is the same kind of algorithm, though not a browser's) agree: cloning { onDone: () => 1 } threw DataCloneError, and a cloned class instance had constructor === Object and no methods.

    Transfer instead of copy

    postMessage(message, transfer) takes a list of transferable objects. MDN: after a transfer the original "is no longer usable", and an ArrayBuffer's memory is moved in a zero-copy operation. MessagePort, ImageBitmap, OffscreenCanvas and streams are also on MDN's list. A typed array is serialisable but not transferable: transfer its .buffer.

    const buf = new ArrayBuffer(8 * 1024 * 1024);
    const moved = structuredClone(buf, { transfer: [buf] });
    console.log(buf.byteLength, moved.byteLength); // 0 8388608  (printed by Node v26.12.1)

    Figure 2. Where the time goes on a round trip

    sequenceDiagram accTitle: A postMessage round trip and who is busy when accDescr: The page serialises the request, which keeps the main thread busy. The worker receives it as a task, rebuilds the data, computes, serialises the reply, and the page receives the reply as a task and rebuilds it, which keeps the main thread busy again. participant P as Page main thread participant W as Worker thread Note over P: serialise request (page busy) P->>W: message becomes a task in the worker's loop Note over W: rebuild data, compute, serialise reply W->>P: message becomes a task in the page's loop Note over P: rebuild reply (page busy)

    How to read this: the page pays twice, to send and to receive. If those two costs approach the compute in the middle, offloading saved nothing.

    How expensive is a message? There is no universal number. Surma measured it in 2019 on devices of that time: even on the slowest, objects up to 100 KiB stayed within a 100 ms response budget, for JavaScript-driven animation he suggested staying under 10 KiB, and transferring an ArrayBuffer was independent of its size. Treat these as an order of magnitude from an older measurement and measure your own payloads on your users' devices.

    Way to send data What happens Strength Watch out for
    Clone (default) Serialise, copy, rebuild Ordinary data; both sides keep a copy Cost grows with size, paid by the page on both ends
    Transfer a buffer Ownership moves; sender's buffer detached Cost independent of size Sender cannot reuse it; you design a binary layout
    SharedArrayBuffer Threads operate on the same memory No message to see updates MDN flags determinism, security and performance concerns
    Send a patch Only what changed Cost follows the change You define and test the patch format

    Designing the boundary

    Keep it small and typed. Every message has a type, an id and plain data. Write the worker's logic as pure functions (data in, data out) and wrap them thinly, so the logic is testable without a browser. Web.dev also points to Comlink, which lets you use workers without handling postMessage directly; it hides the protocol, not its costs.

    Send less. Send what the worker cannot already have (a file, a changed record), not the whole state on every keystroke, and return a summary rather than a million rows the page must rebuild.

    Queue on purpose. A pool is a queue in front of a few workers. When jobs arrive faster than workers finish, every new job waits longer. Pick a policy: a bounded queue, newest-wins for interactive work, first-in-first-out for batch. navigator.hardwareConcurrency is a starting point for pool size, to be measured: the standard says it is between 1 and the number of logical processors, is 1 when it cannot be determined, and may be lowered for limits or to reduce fingerprinting. The main thread needs a core too.

    Cancel cooperatively, terminate rarely. A running worker cannot be interrupted by a message, which waits for the current task. Check a flag between slices of work, or call worker.terminate(), which MDN says kills the thread immediately, with its state and start-up cost.

    Figure 3. Life of one job

    stateDiagram-v2 accTitle: States of a job sent to a worker pool accDescr: A job is queued, then running when a worker takes it, then done when the reply arrives. A queued job can be dropped when a newer job supersedes it. A running job that is superseded finishes anyway and its result is ignored. [*] --> Queued: page posts Queued --> Running: a worker is free Queued --> Dropped: superseded before it starts Running --> Done: reply arrives Running --> Stale: superseded while running Stale --> Done: result ignored by id Done --> [*] Dropped --> [*]

    How to read this: only a queued job can be cancelled for free. A running job holds a worker until it ends, so keep jobs short or sliced if cancellation matters.

    Illustration 2: a realistic example

    A report page parses a CSV export of about 40 MB (illustrative). On the main thread that is one long task. With a worker, the page reads the file into an ArrayBuffer, transfers it, and gets back a few kilobytes of aggregates. The shape that makes this a good candidate is large in, small out, no DOM.

    Illustration 3: failures and edge cases

    • A function in the message throws DataCloneError. Send a message name instead.
    • Using a buffer after the transfer. Its byteLength is 0 and reading the length does not throw. In Node v26.12.1 a Uint8Array over a detached buffer threw TypeError, TextDecoder.decode returned an empty string, and a second transfer threw DataCloneError.
    • Cloning the world on each event. A 5 MB (illustrative) state posted per keystroke keeps the page busy with serialisation every time. Send patches.
    • Too many workers. The standard calls them heavy: dozens cost start-up and memory without throughput beyond the cores.

    Beyond dedicated workers: worklets and OffscreenCanvas

    MDN calls a Worklet a lightweight version of Web Workers with access to low-level parts of the rendering pipeline, restricted to specific uses and not for arbitrary computation. It lists AudioWorklet, AnimationWorklet, LayoutWorklet and SharedStorageWorklet, and notes paint worklets come from CSS.paintWorklet. You load code with addModule(); dynamic import() is not allowed. Support differs by type, so check MDN's table for the one you need. Use one when the platform asks for it, such as custom audio.

    MDN says OffscreenCanvas decouples the canvas from the DOM so rendering can run in a worker, is transferable, and transferControlToOffscreen() hands a visible canvas to a worker. It marks it Baseline widely available since March 2023, with some parts varying. It suits heavy canvas or WebGL drawing, not DOM-bound work.

    Figure 4. Picking where the work runs

    flowchart TD accTitle: Choosing where heavy work should run accDescr: If the work needs the DOM, chunk it and yield. If it draws to a canvas, use OffscreenCanvas in a worker. If the platform needs custom audio code, use a worklet. Otherwise use a worker or small pool, transferring large inputs. A{"Needs the DOM?"} -->|"yes"| B["Chunk and yield on the main thread"] A -->|"no"| C{"Draws to a canvas?"} C -->|"yes"| D["OffscreenCanvas in a worker"] C -->|"no"| E{"Platform hook such as
    audio processing?"} E -->|"yes"| F["Worklet"] E -->|"no"| G["Dedicated worker or small pool"] G --> H{"Large input?"} H -->|"yes"| I["Transfer the buffer, return a summary"] H -->|"no"| J["Clone small messages"]

    How to read this: the first question is whether the work needs the DOM. If it does, a worker cannot do it, and the answer is the chunking from the previous lesson.

    Build it: a model of the message boundary

    Goal. See, in a deterministic model with a virtual clock, how clone and transfer change how long the main thread stays busy, and how a pool's queue behaves, with the model's clone semantics checked against the runtime's own structuredClone.

    This is a model of the idea, not a browser. There is no real thread, Worker, event loop or timing. The costs are parameters in demo.ts. The only real behaviour compared is Node.js's structuredClone, which is not a browser's.

    Setup. The lab is in the course's example workspace (Node.js, captured on 26.12.1, which runs TypeScript directly, and pnpm).

    cd examples/frontend
    pnpm install
    pnpm --filter ./labs/worker-message-model test   # 30 tests
    cd labs/worker-message-model
    node src/demo.ts                                  # prints the output below

    Steps.

    1. Part 1 runs nine cases through the model and structuredClone. Every line must say agree; the demo exits non-zero otherwise.
    2. In part 2, the clone column grows with buffer size (1 KiB to 16 MiB) and the transfer column stays flat: the shape MDN and Surma describe, not a measurement.
    3. In part 3, run A (inline) is one 480 ms block. Run B moves the compute off the page, but cloning 2 MiB each way keeps it busy 164.65 virtual ms, with a longest block of 82.32 (a long task by the 50 ms rule). Run C transfers: 1.61 busy, but one worker still queues jobs (longest wait 331.00). Run D adds workers: longest wait 29.80, essentially start-up.
    4. Run E shows four workers do not spare the page if every message is cloned.

    Output captured from a real run (Node 26.12.1, 2026-11-08):

    === part 1: what postMessage does to a value (model versus this runtime's structuredClone)
    1. nested data is copied, not shared
       model: copy.list.length after the sender pushes: 3
       real:  copy.list.length after the sender pushes: 3
       agree
    2. a cycle stays a cycle
       model: copy.self === copy: true
       real:  copy.self === copy: true
       agree
    3. a class instance arrives as a plain object
       model: prototype is Object.prototype: true; has norm(): false
       real:  prototype is Object.prototype: true; has norm(): false
       agree
    4. a function cannot be sent
       model: DataCloneError
       real:  DataCloneError
       agree
    5. a Map and a Set are copied with their contents
       model: map size 1, set size 2
       real:  map size 1, set size 2
       agree
    6. sending a buffer by copy leaves the sender's buffer intact
       model: sender byteLength afterwards: 4
       real:  sender byteLength afterwards: 4
       agree
    7. transferring a buffer detaches the sender's copy
       model: sender byteLength afterwards: 0; receiver byteLength: 4
       real:  sender byteLength afterwards: 0; receiver byteLength: 4
       agree
    8. a typed array is not itself transferable (transfer its buffer)
       model: DataCloneError
       real:  DataCloneError
       agree
    9. transferring the same buffer twice fails the second time
       model: DataCloneError
       real:  DataCloneError
       agree
    
    === part 2: cost of one side of one message (virtual ms; model parameters, not measurements)
    cost model: 0.1 ms per message + 0.01 ms per cloned model KiB + 0.1 ms per transferred buffer
    buffer size    clone     transfer
    1 KiB          0.11     0.20
    100 KiB        1.10     0.20
    2 MiB         20.58     0.20
    16 MiB       163.94     0.20
    
    === part 3: four 2 MiB image jobs, 120 ms of compute each, submitted at t=0, 20, 40, 60 (virtual ms)
    cost model as in part 2; a worker needs 30 ms to start; "long" means a main-thread block over 50 ms
    
    --- A. inline on the main thread
    main thread busy   480.00   longest block   480.00   long tasks 1
    last result at     480.00   mean latency    270.00   longest queue wait     0.00
    job     submit  worker-start  worker-end      done  worker
    job1      0.00      0.00      120.00    120.00  main
    job2     20.00    120.00      240.00    240.00  main
    job3     40.00    240.00      360.00    360.00  main
    job4     60.00    360.00      480.00    480.00  main
    
    --- B. 1 worker, data cloned
    main thread busy   164.65   longest block    82.32   long tasks 1
    last result at     695.23   mean latency    423.48   longest queue wait   431.16
    job     submit  worker-start  worker-end      done  worker
    job1      0.00     30.00      191.16    211.74  0
    job2     20.00    191.16      352.32    372.90  0
    job3     40.00    352.32      513.48    534.06  0
    job4     60.00    513.48      674.65    695.23  0
    
    --- C. 1 worker, buffers transferred
    main thread busy     1.61   longest block     0.20   long tasks 0
    last result at     511.81   mean latency    301.20   longest queue wait   331.00
    job     submit  worker-start  worker-end      done  worker
    job1      0.00     30.00      150.40    150.60  0
    job2     20.00    150.40      270.80    271.00  0
    job3     40.00    270.80      391.20    391.40  0
    job4     60.00    391.20      511.61    511.81  0
    
    --- D. 4 workers, buffers transferred
    main thread busy     1.61   longest block     0.40   long tasks 0
    last result at     180.80   mean latency    130.75   longest queue wait    29.80
    job     submit  worker-start  worker-end      done  worker
    job1      0.00     30.00      150.40    150.60  0
    job2     20.00     30.00      150.40    150.80  1
    job3     40.00     40.20      160.60    160.80  2
    job4     60.00     60.20      180.60    180.80  3
    
    --- E. 4 workers, data cloned
    main thread busy   164.65   longest block    82.32   long tasks 2
    last result at     273.48   mean latency    212.61   longest queue wait     9.42
    job     submit  worker-start  worker-end      done  worker
    job1      0.00     30.00      191.16    211.74  0
    job2     20.00     41.16      202.32    232.32  1
    job3     40.00     61.74      222.90    252.90  2
    job4     60.00     82.32      243.49    273.48  3
    
    Reading the table: these numbers come from the cost model above, not from a browser.
    They show the shape: cloning keeps the main thread busy in proportion to size, a transfer does not,
    and a queue behind too few workers shows up as waiting time, not as main-thread work.

    Break it. In src/clone.ts, in cloneModel, delete the last loop (for (const buffer of transfer) buffer.transfer();). A "transferred" buffer is no longer detached: four tests fail and the demo prints DIFFERENT for cases 7 and 9 and exits non-zero. Revert it.

    Stretch. Give the receiver its own cost parameter; add cancellation between jobs and show a stale job still finishing.

    In practice: a CSV reporting dashboard

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    An analytics page lets analysts upload CSV exports up to about 40 MB, parses them and draws three charts. The parse blocks the main thread for seconds, the progress bar stops, and users click twice. Three engineers own the page.

    Starting architecture

    Figure 5. Everything on the main thread

    Starting architecture: reading, parsing, aggregating and drawing in one threadCSV file to Parse, aggregate (text); Parse, aggregate to Draw charts (rows)MAIN THREADCSV file: up to ~40 MBCSV fileup to ~40 MBParse, aggregate: one long taskParse,aggregateone long taskDraw charts: DOM and canvasDraw chartsDOM and canvastextrows

    How to read this: the warning boxes share the thread that also handles clicks and paints the progress bar.

    Options considered

    Option What it fixes What it costs Risk
    Chunk the parse and yield No long task, no new infrastructure Async parser, more total time Still competes with input
    One worker, transfer the file Parse off the main thread; no copy of the input Worker file, protocol, build setup A second upload queues behind the first
    Pool, split the file Parallel parse Safe row splitting, merging Complexity beyond the need
    Parse on the server Client does little Upload, API, storage Latency, data-privacy review

    The decision

    Field Decision record
    Context Parsing a large CSV on the main thread freezes the page for seconds.
    Decision One dedicated worker. Read the file into an ArrayBuffer, transfer it, return aggregates only. Messages carry a jobId; the page ignores replies for an older job.
    Consequences, good Page stays responsive; the large input crosses without a copy; the parser is a pure function tested without a browser.
    Consequences, bad A worker and protocol to maintain; progress needs explicit messages; stale jobs still occupy the worker.
    Revisit when Concurrent uploads become common (small pool), or aggregates grow until the reply costs the page noticeably.

    Rollout and verification

    1. Test the parser as a pure function with fixtures, including a malformed row.
    2. Test the protocol with a fake worker that replies out of order: a stale jobId must be ignored.
    3. Check in a real browser with CPU throttling: record a profile and confirm no long task during the parse.
    4. Ship behind a flag and compare abandonment of the upload page.

    The 2 a.m. failure

    Support reports that "Export again" on the same upload gives blank charts and no error. The retry reuses the ArrayBuffer transferred to the worker on the first attempt. After a transfer the sender's buffer is detached with byteLength 0, so the second parse sees an empty file. How that surfaces depends on how the parser reads it: in Node v26.12.1 a typed-array view over it throws, while TextDecoder.decode returns an empty string. In this composite the parser decodes with TextDecoder and treats empty input as zero rows, so nothing fails.

    First three checks: (1) log buffer.byteLength before each postMessage; (2) search for code that keeps a buffer after transferring it; (3) confirm the worker's received length equals the file size. Mitigation: re-read the File on each attempt. Follow-up: a helper that takes a File, reads it and transfers a fresh buffer every time, plus a test that sends the same file twice.

    Architect's notes

    • Move work that is large in and small out. A transfer makes the boundary cheap and the reply cheap to clone.
    • Judge by main-thread behaviour. Web.dev says off-main-thread architecture is about reducing risk and does not by itself make an app faster. Watch long tasks and interaction latency.
    • Keep logic pure and the wrapper thin.
    • Version the protocol. The page bundle and worker script can be cached separately.
    • Own the queue. Decide bounds, ordering and cancellation before the first incident.

    Common mistakes

    • Treating a worker as free parallelism. It adds messages, start-up and two-sided state.
    • Posting large objects on every event.
    • Reusing a transferred buffer.
    • Sending functions or class instances.
    • Expecting a message to interrupt a running job.
    • Launching many workers.
    • Using a worker for DOM work.

    Key takeaways

    • A worker is a thread with its own event loop and no DOM, reached only by messages.
    • A message is structured-cloned by default; functions throw DataCloneError.
    • A transfer list moves an ArrayBuffer; the sender's copy is detached.
    • The page pays for sending requests and receiving replies, so large replies can eat the gain.
    • Design the boundary: typed messages with ids, small payloads, patches, a queue policy, cooperative cancellation.
    • Worklets serve specific rendering and audio hooks; OffscreenCanvas moves canvas drawing; DOM work stays on the main thread.
    • Offloading reduces main-thread risk, not total work.

    Practice exercises

    Exercise 1 — Starter (30 min): In Node, structuredClone an object holding a Map, a Date, a class instance and a cycle, then one holding a function. Write down what changed and what threw.

    Exercise 2 — Intermediate (2 hours): In the lab, set the cost parameters so a transfer costs as much as cloning 100 KiB. Predict which of runs B to E change ranking, then check.

    Exercise 3 — Advanced (half day): Build a page whose worker sorts 1 million numbers, once cloned and once as a transferred Float64Array buffer, and compare main-thread time in a throttled profile.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments