Skip to main content
On this page

Web Workers and Worklets for Off-Main-Thread Work

Workers and worklets are two different answers to the same constraint: every page has exactly one main thread, and that thread is responsible for JavaScript, style and layout, paint, and event delivery. Anything that ties the main thread up beyond a frame budget shows up as jank. Workers move work to a sibling thread and communicate over postMessage(); they are general-purpose and asynchronous. Worklets are short-lived script contexts that the browser invokes synchronously inside a specific rendering pipeline stage; they are narrow-purpose and timing-sensitive. Confusing the two is the most common mistake — workers are the wrong tool for paint or audio, and worklets are the wrong tool for parsing a CSV.

Workers vs worklets at a glance: workers communicate asynchronously over postMessage; worklets are invoked synchronously by the paint, compositor, and audio render threads.
Workers vs worklets at a glance: workers communicate asynchronously over postMessage; worklets are invoked synchronously by the paint, compositor, and audio render threads.

Mental Model

Three primitives, each with a distinct ownership model, cover almost every off-main-thread case on the web platform:

  1. Workers. Independent OS-level threads with isolated heaps and event loops, owned by the page (or the browser, in the Service Worker case). Cross-context communication uses structured cloning by default, transferable objects for zero-copy handoffs, and SharedArrayBuffer for true shared memory under cross-origin isolation.
  2. Worklets. Short-lived script contexts attached to a specific pipeline phase (paint, compositor, or audio render thread), defined by the Houdini Worklets spec. They have no DOM, no fetch, no setTimeout. Their power is that the browser can call them at the exact moment a frame, paint, or audio block is being produced.
  3. Cross-thread coordination. Atomics on top of SharedArrayBuffer for lock-free synchronization; Atomics.waitAsync for non-blocking waits when running on the main thread.

The article walks through each layer with a bias toward what is actually shipped today and what is still on the drawing board.

Worker Types and Lifecycle

The HTML Living Standard defines a worker as an agent with its own event loop, JavaScript heap, and global scope. It is a sibling of the document, not a child of any DOM tree.

Dedicated Workers

A Dedicated Worker is a 1:1 channel between a creator and a worker thread, exposed through the Worker interface.

Creating a dedicated worker
const worker = new Worker("/compute-worker.js", {  type: "module", // Enable ES modules (default: 'classic')  name: "compute", // Optional name for debugging})worker.postMessage({ task: "factorial", n: 1000000n })

The HTML spec defines a worker processing model in which each worker tracks an owner set, a closing flag, and a terminate-on-impermissible rule. The lifecycle states are:

State Condition Behavior
Actively needed Has a fully active Document owner, or is owned by another actively needed worker Full execution permitted
Protected Actively needed AND (a SharedWorker, has open ports, or has timers/network) Cannot be garbage collected
Permissible Has any owner, or is a SharedWorker within the between-loads timeout May be suspended
Suspendable Permissible but not actively needed UA may pause its event loop

Worker.terminate() sets the closing flag, discards queued tasks, and aborts the running script — there is no graceful shutdown hook. If you need cooperative shutdown, model it explicitly with a final message and a close() call inside the worker.

Shared Workers

Shared Workers implement many-to-one: every browsing context within the same origin that addresses the worker by URL and name connects to the same instance.

Shared worker connection
const worker = new SharedWorker("/shared-state.js", "session-state")worker.port.start()worker.port.postMessage({ action: "subscribe", channel: "updates" })worker.port.onmessage = (e) => {  console.log("Update:", e.data)}}

Differences from a Dedicated Worker:

  • Communication uses an explicit MessagePort accessed via worker.port. The port is implicitly started for onmessage listeners but must be explicitly started when using addEventListener('message', ...).
  • The worker survives as long as at least one port remains connected.
  • onconnect fires once per new context; the worker maintains its own connections set.
  • State persists across page navigations within the same origin via the spec’s between-loads shared worker timeout, which lets a same-origin reload reattach without losing state.

Note

Browser support for Shared Workers was uneven for years. Safari shipped them on macOS in version 16 (2022) and on iOS in Safari 16.4 (March 2023), so every iOS WebKit-derived browser inherits support automatically. Older iOS baselines still ship in production, so for cross-tab features that have to work on the long tail, fall back to BroadcastChannel plus per-tab Dedicated Workers.

Service Workers

Service Workers operate under browser control rather than page control. The browser starts them in response to network or background events and terminates them when idle.

Service Worker lifecycle: install → installed → activating → activated → idle, with browser-controlled termination after roughly 30 seconds of inactivity. waitUntil keeps the worker alive while async work is outstanding.
Service Worker lifecycle: install → installed → activating → activated → idle, with browser-controlled termination after roughly 30 seconds of inactivity. waitUntil keeps the worker alive while async work is outstanding.

Service worker registration
if ("serviceWorker" in navigator) {  const reg = await navigator.serviceWorker.register("/sw.js", {    scope: "/",    type: "module",  })}

The behavioural contract differs sharply from Dedicated and Shared Workers:

Aspect Dedicated/Shared Worker Service Worker
Lifetime Developer controls via new/terminate Browser controls; may terminate any time
Persistence Lives while page is open Persists across page loads and browser restarts
Network Regular fetch access Intercepts all fetch requests in scope
DOM access None None (talks to clients via Client.postMessage)
Start trigger Explicit construction fetch, push, sync, notificationclick, etc.

Event handlers must complete quickly. Long-running work belongs inside event.waitUntil(promise), which keeps the worker alive until the promise settles. Even with waitUntil, the browser will eventually evict an idle worker — Chromium terminates after roughly 30 seconds of inactivity and caps a single in-flight event at about 5 minutes (the heuristics are documented for extension service workers, and the same Blink scheduler runs page service workers). Treat Service Worker memory as ephemeral and persist anything you need in caches, IndexedDB, or the Origin Private File System.

Important

Service Workers register against an HTTPS origin (or localhost for development). The script’s URL determines the maximum scope. A worker at /scripts/sw.js cannot control /; either move the script to the root or set the Service-Worker-Allowed response header to widen its scope.

Module Workers vs Classic Workers

The type option on Worker and ServiceWorkerContainer.register controls script loading semantics.

Classic workers (type: 'classic', the default):

  • importScripts() synchronously loads and executes scripts.
  • import / export statements throw SyntaxError.
  • Strict mode is opt-in.
  • Top-level this references the global scope.

Module workers (type: 'module'):

  • ES module semantics, including import / export and dynamic import().
  • importScripts() always throws TypeError.
  • Strict mode is on and cannot be disabled.
  • Top-level this is undefined.
  • import.meta.url resolves to the worker’s script URL.
  • <link rel="modulepreload"> can warm up the dependency graph before construction. See the web.dev module workers guide for the deployment notes.
Module worker with dynamic import
import { heavyCompute } from "./compute.js"self.onmessage = async (e) => {  // Dynamic import for code splitting  const { processImage } = await import("./image-processor.js")

Module workers have slightly higher cold-start cost because the browser must resolve and fetch the dependency graph before running any code, but they unlock tree-shaking and shared module deduplication with the main thread. Pick module workers by default; reach back for classic workers only when you must importScripts() from a runtime-resolved list.

Communication and Data Transfer

postMessage is the seam between threads. Two algorithms govern what crosses that seam: the Structured Clone Algorithm and the transfer protocol.

Structured clone copies the data graph end-to-end and the original keeps full ownership. Transferring detaches the resource from the source and hands the backing memory to the worker as a zero-copy operation.
Structured clone copies the data graph end-to-end and the original keeps full ownership. Transferring detaches the resource from the source and hands the backing memory to the worker as a zero-copy operation.

Structured Clone Algorithm

postMessage() serializes data with the Structured Clone Algorithm, which walks the object graph, copies platform values, and tracks references to preserve cycles.

Supported types (per HTML §2.7):

  • Primitives (except Symbol).
  • Object, Array, Map, Set.
  • Date, RegExp.
  • ArrayBuffer, TypedArray, DataView.
  • Blob, File, FileList.
  • ImageData, ImageBitmap.
  • Error (a fixed subset of properties).

Cannot be cloned — throws DataCloneError:

  • Functions, including bound methods.
  • DOM nodes.
  • Property descriptors and getters/setters (only own enumerable data properties survive).
  • Prototype chain (instances arrive as plain objects).
  • Symbol keys.
Structured cloning behavior
worker.postMessage(obj) // Works—cycle is maintained// Functions failworker.postMessage({ fn: () => {} }) // DataCloneError// Class instances lose their prototypeclass Point {  constructor(x, y) {    this.x = x    this.y = y  }// Worker receives: Map { 'key' => 'value' }

The same algorithm powers structuredClone(), so anything that survives a postMessage survives an in-process deep clone too.

Transferable Objects

Transferables move ownership instead of copying. The source context detaches the resource, the destination context attaches it, and the underlying memory is never duplicated.

The complete list of transferable types, per the HTML spec and per-API specs:

Family Transferables
Memory ArrayBuffer
Messaging MessagePort
Graphics ImageBitmap, OffscreenCanvas
Streams ReadableStream, WritableStream, TransformStream
Media MediaStreamTrack, MediaSourceHandle
WebCodecs AudioData, VideoFrame
WebRTC / WebMIDI RTCDataChannel, MIDIAccess
WebTransport WebTransportReceiveStream, WebTransportSendStream

Caution

Typed arrays (Uint8Array, Float32Array, …) are serializable but not transferable. Pass arr.buffer in the transfer list, not arr. The receiving side gets a fresh typed array view over the moved buffer.

Transferring an ArrayBuffer
console.log(buffer.byteLength) // 104857600// Transfer ownership to workerworker.postMessage(buffer, [buffer])console.log(buffer.byteLength) // 0 (detached)// Worker receives the buffer instantly

Performance Characteristics

Cloning cost is roughly linear in the size and complexity of the data graph; transfer cost is constant. The numbers below are order-of-magnitude figures from the Chrome team’s “Transferable Objects: Lightning Fast” post and follow-up benchmarks — treat them as a sanity check, not a contract.

Data size Approx. clone time Approx. transfer time Recommendation
< 10 KB < 1 ms ~0 ms Either method is acceptable
50 KB ~5 ms < 1 ms Clone for one-shot calls; transfer above ~60 Hz
100 KB ~10 ms < 1 ms Transfer if you need to stay inside a 16 ms frame
1 MB ~100 ms < 1 ms Always transfer
32 MB ~300 ms a few ms Always transfer
> 200 MB unreliable tens of ms Stream or chunk; consider SharedArrayBuffer instead

For high-frequency channels (e.g. 60 Hz animation frames or 1 kHz sensor samples), even small cloning overhead piles up. Pre-allocate the buffer pool, transfer the same buffers back and forth, and avoid recreating typed arrays per frame.

Error Handling

Worker errors propagate via the error event on the constructor side and unhandledrejection for promises:

Worker error handling
  console.error(`${event.filename}:${event.lineno}:${event.colno}`)  console.error(event.message)  event.preventDefault() // Suppress default console error}worker.onmessageerror = (event) => {  // Fires when deserialization fails  console.error("Failed to deserialize message")}

messageerror fires when the receiving side cannot deserialize a message — typically because a transferable was already detached or a serialized class no longer matches the expected shape. Unhandled promise rejections inside the worker fire unhandledrejection on the worker global scope, mirroring the main-thread API.

Higher-level abstractions

Hand-rolling request/response IDs on top of postMessage is repetitive and easy to get wrong. Comlink wraps both ends in a Proxy so the worker exports look like ordinary async methods on the main thread:

Comlink-wrapped worker
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" })const api = Comlink.wrap(worker)const result = await api.factorial(1_000_000n)// worker.jsimport * as Comlink from "comlink"Comlink.expose({  factorial(n) { /* ... */ },

Comlink uses the same structured clone + transferable rules under the hood, so the cost model is identical — it removes boilerplate, not overhead. For zero-copy paths, pass transferables explicitly via Comlink.transfer(value, [arrayBuffer]).

SharedArrayBuffer and Atomics

SharedArrayBuffer (SAB) gives every connected agent a view onto the same physical bytes. The price is cross-origin isolation, because shared memory plus a high-resolution counter is enough to defeat the Spectre side-channel mitigations the platform put in place after 2018.

Cross-Origin Isolation Requirements

Three pieces have to line up before SharedArrayBuffer is exposed to your page (per the MDN COEP guide):

Http
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp

Plus, if a parent has dropped it, the cross-origin-isolated permission policy must allow the feature in the current frame.

  • COOP: same-origin severs the relationship between your top-level document and any cross-origin popups, so a malicious opener cannot reach into your window.
  • COEP: require-corp requires every cross-origin subresource to opt in via a Cross-Origin-Resource-Policy header (or use crossorigin on the embedding tag).
  • The credentialless variant of COEP is the pragmatic fallback when you embed third-party assets that you cannot make CORS-aware: cross-origin no-cors fetches go through, but the browser strips cookies and credentials so the response cannot leak per-user data.
Checking cross-origin isolation
}const sab = new SharedArrayBuffer(1024)const view = new Int32Array(sab)

Window.crossOriginIsolated is the runtime feature switch; check it before constructing a SAB and have a clone-based fallback for cases where the headers cannot be set (third-party iframes, classic CDNs, etc.).

Atomics Operations

Atomics provides the synchronization primitives. Each operation maps onto a hardware atomic instruction; semantics are sequentially consistent within the JavaScript memory model defined in ECMA-262 §29.

Producer-consumer with Atomics
const control = new Int32Array(sab, 0, 1)const data = new Float64Array(sab, 8)// In producer worker:function produce(value) {  data[0] = value  Atomics.store(control, 0, 1) // Mark ready  Atomics.notify(control, 0, 1) // Wake one waiter}// In consumer worker:function consume() {  while (Atomics.load(control, 0) === 0) {    Atomics.wait(control, 0, 0) // Block until notified  }  const value = data[0]  Atomics.store(control, 0, 0) // Mark consumed  return value}

Warning

Atomics.wait() blocks the calling agent. Calling it on the main thread freezes input handling, layout, and paint — and most browsers refuse to do it at all for that reason. Use Atomics.waitAsync() (Baseline newly available since November 2025) to get a Promise-based wait that the main thread can await without stalling the event loop.

Design Rationale: Why Cross-Origin Isolation?

Shared memory plus a worker that increments a counter as fast as possible is a high-resolution clock. Combined with a microarchitectural side-channel like Spectre, that clock is enough to read memory across the same address space.

High-resolution timing via SharedArrayBuffer
// The delta reveals cache timing, potentially leaking memory contentsconst sab = new SharedArrayBuffer(4)const counter = new Int32Array(sab)// ... memory access ...const elapsed = Atomics.load(counter, 0) - start

Cross-origin isolation contains this threat by:

  1. Preventing cross-origin documents from sharing the agent cluster (so they cannot map the same SAB).
  2. Throttling performance.now() and Date.now() resolution outside isolated contexts.
  3. Forcing every cross-origin subresource to explicitly opt in, so a malicious ad cannot piggy-back inside an isolated document.

The web.dev cross-origin isolation guide walks through the rollout strategy — COOP-Report-Only and COEP-Report-Only first, then credentialless for partial coverage, then require-corp once every embedded asset has CORP headers.

Worklets: Rendering Pipeline Integration

Worklets exist because workers cannot meet the timing contract of the rendering pipeline. The Houdini Worklets specification defines them as short-lived global scopes that the user agent invokes synchronously inside the relevant pipeline stage. There may be more than one global scope per worklet — the spec allows the UA to instantiate the class multiple times for parallelism — so worklet code must be stateless across invocations.

Why Worklets Exist

Workers communicate asynchronously, which means their results arrive after the frame they were intended for has already been painted:

Why workers fail for paint
  element.style.backgroundImage = `url(${e.data})`  // User sees a frame with the OLD background}

Worklets are called synchronously inside the phase that needs them:

CSS
/* Paint worklet runs during paint phase */.custom-bg {  background: paint(custom-gradient);}/* Browser calls paint() synchronously before compositing */

Worklet Restrictions

Synchronous integration costs flexibility. The Worklet global scope is intentionally minimal:

Restriction Reason
No DOM access Would require cross-thread synchronization
No fetch() or network Would block rendering
No setTimeout/setInterval Timing tied to render events
Limited global scope Prevents accidental expensive operations
Multiple instances Browser may parallelize execution

The browser may create multiple worklet global scopes and instantiate your class multiple times. Never rely on instance state persisting across invocations; persist any cross-frame state through arguments and class fields that you treat as immutable.

Paint Worklet (CSS Painting API)

Paint Worklets generate custom CSS <image> values during the paint phase. They register a class with a paint(ctx, geom, props) method; the browser calls it whenever a paint(name) CSS function needs to be resolved.

Registering a paint worklet
  static get inputProperties() {    return ["--checker-size", "--checker-color-1", "--checker-color-2"]  }  paint(ctx, geom, props) {    const size = parseInt(props.get("--checker-size")) || 32    const color1 = props.get("--checker-color-1").toString() || "#fff"    const color2 = props.get("--checker-color-2").toString() || "#000"    for (let y = 0; y < geom.height; y += size) {      for (let x = 0; x < geom.width; x += size) {        const isEven = (x / size + y / size) % 2 === 0        ctx.fillStyle = isEven ? color1 : color2        ctx.fillRect(x, y, size, size)      }    }  }}registerPaint("checkerboard", CheckerboardPainter)
Using the paint worklet
  --checker-size: 20;  --checker-color-1: #f0f0f0;  --checker-color-2: #333;  background: paint(checkerboard);}

PaintRenderingContext2D is a strict subset of CanvasRenderingContext2D:

  • Supported: fillRect, strokeRect, drawImage, arc, bezierCurveTo, path operations, transforms.
  • Not supported: text rendering (fillText, strokeText), getImageData, putImageData. Text is excluded because it would require pulling font metrics into the paint context.

Browser behaviour to be aware of:

  • The browser may cache results when size, custom property values, and arguments are unchanged.
  • Elements outside the viewport may defer painting until they scroll into view.
  • A paint() call that takes too long is terminated and the painted area falls back to transparent.

Support (as of 2026): Chromium-based browsers ship the API; Safari and Firefox do not (caniuse: css-paint-api). Use the css-paint-polyfill for cross-browser parity, or treat custom paint as a progressive enhancement layered on top of a static background.

Animation Worklet

AnimationWorklet was Houdini’s answer to scripted, jank-resistant animations driven by ScrollTimeline or DocumentTimeline. The animator class controls effect.localTime directly, in principle running on the compositor thread.

Scroll-linked animation worklet (legacy API)
  constructor(options) {    this.rate = options.rate || 0.5  }  animate(currentTime, effect) {    // currentTime comes from the timeline (e.g., scroll position)    // effect.localTime controls the animation progress    effect.localTime = currentTime * this.rate  }}registerAnimator("parallax", ParallaxAnimator)// main.jsawait CSS.animationWorklet.addModule("/parallax-animator.js")  { rate: 0.3 },)animation.play()

Important

Animation Worklet was an Origin Trial in Chrome 71–74 and never shipped to stable. Safari and Firefox have no implementation. The use cases the API was designed for — scroll-linked timelines, view-driven animations — are now solved by CSS Scroll-driven Animations (animation-timeline, scroll-timeline, view-timeline), which ship in Chromium 115+, Safari 26+, and behind a flag in Firefox. Treat Animation Worklet as a historical primitive; reach for declarative scroll-driven animations for new work.

The takeaway is structural: when a Houdini API ends up in this state, the platform usually answers with a more declarative replacement that the compositor can optimise without scripted callbacks. Audio Worklet is the counterexample, and it survived precisely because audio has no declarative replacement.

Audio Worklet

AudioWorklet processes audio samples on the Web Audio rendering thread under hard real-time constraints. Each process() call has to fill the output before the next render quantum is due.

Custom gain processor
  static get parameterDescriptors() {    return [      {        name: "gain",        defaultValue: 1.0,        minValue: 0,        maxValue: 2,        automationRate: "a-rate", // per-sample automation      },    ]  }  process(inputs, outputs, parameters) {    const input = inputs[0]    const output = outputs[0]    const gain = parameters.gain    for (let channel = 0; channel < output.length; channel++) {      for (let i = 0; i < output[channel].length; i++) {        // gain may be k-rate (single value) or a-rate (per-sample)source.connect(gainNode)gainNode.connect(ctx.destination)

Render quantum: Fixed at 128 sample-frames per the Web Audio API spec and MDN’s AudioWorkletProcessor.process() reference. At 48 kHz, that is roughly 2.67 ms per callback. Web Audio v2 introduces a renderSizeHint for AudioContextOptions to negotiate other sizes (W3C Web Audio 1.1 §2.2), but as of early 2026 it is not yet broadly available — design for 128 unless you have explicitly negotiated otherwise.

Communication. Use a MessagePort (accessed via node.port / this.port) for control messages. Allocations and I/O inside process() are unsafe — the audio thread cannot tolerate a GC pause or an OS scheduling hiccup. Pre-allocate every buffer and parameter array in the constructor.

Support (as of 2026): All modern browsers — the API has been Baseline since Safari 14.1 in 2021 and is the production-ready primitive in the Houdini family.

Layout Worklet (Experimental)

The CSS Layout API lets authors implement custom layout algorithms (masonry, true 2-D flow, custom grids). The API is Chromium-only, gated behind a flag, and the spec is still in flux.

Layout worklet (experimental)
class MasonryLayout {  static get inputProperties() {    return ["--columns"]  }  async intrinsicSizes(children, edges, styleMap) {    // Return intrinsic sizing  }  async layout(children, edges, constraints, styleMap) {    // Position children, return fragment  }}registerLayout("masonry", MasonryLayout)

Status (as of 2026): Behind flags in Chromium, no signal from WebKit or Gecko. Specification still evolving. Not production-ready.

Use-Case Decision Matrix

When more than one primitive could work, picking the wrong one usually shows up as either jank (worker results arriving too late) or starvation (worklet code attempting work it cannot do). The decision tree below collapses the choice down to one or two questions.

Decision tree: pick a worklet when the work has to land inside a render-pipeline stage; pick a worker otherwise; reach for SharedArrayBuffer only when lock-free shared memory is genuinely required.
Decision tree: pick a worklet when the work has to land inside a render-pipeline stage; pick a worker otherwise; reach for SharedArrayBuffer only when lock-free shared memory is genuinely required.

Scenario Recommended Why
Heavy computation (WASM, crypto, parsing) Dedicated Worker Isolates CPU work from UI
Cross-tab shared state Shared Worker (with BroadcastChannel fallback) Single instance serves multiple tabs
Offline support, caching Service Worker Intercepts network, persists across sessions
Custom CSS backgrounds/borders Paint Worklet (Chromium) + polyfill Synchronous with paint phase
Scroll-linked animations CSS Scroll-driven Animations Animation Worklet never shipped
Real-time audio effects Audio Worklet Runs on audio thread, < 3 ms latency
Large binary transfers (> 1 MB) Worker + Transferables Zero-copy transfer
Lock-free shared state SharedArrayBuffer + Atomics True shared memory (requires COOP/COEP)
Per-frame off-main-thread compositing OffscreenCanvas in a Dedicated Worker Renders to a transferable canvas

Debugging and Profiling

Chrome DevTools

Workers

  • The Sources panel’s Threads sidebar lists every active worker; pause and console-eval target the selected one.
  • The Performance panel includes worker activity in the flame chart and surfaces transfer / clone events on the timing track.
  • The Memory panel can take heap snapshots of any worker.

Worklets

  • Paint Worklets: enable Paint flashing in the Rendering drawer to see when a paint() invocation runs.
  • Audio Worklets: visit chrome://webaudio-internals to inspect the live audio graph and see processor latency.
  • The Performance panel shows worklet execution on the appropriate compositor or audio track.

Common Issues

Worker won’t start:

  • Check the console for fetch errors — 404s, CORS, or mixed content.
  • Module workers need a JavaScript MIME type on the response (text/javascript or application/javascript).
  • Mixed content blocks an HTTP worker URL on an HTTPS page.

Messages aren’t arriving:

  • Confirm port.start() has been called on SharedWorker and MessageChannel ports when using addEventListener (the onmessage setter starts the port implicitly).
  • Watch for DataCloneError in the console — usually a function or DOM node sneaking into the payload.
  • Make sure the worker hasn’t been terminated (worker.terminate() is immediate and silent).

SharedArrayBuffer is undefined:

  • Verify crossOriginIsolated === true from a console in the affected document.
  • Check the response headers for both COOP and COEP, and that no parent frame’s Permissions-Policy is blocking it.
  • Make sure every cross-origin subresource has Cross-Origin-Resource-Policy set, or switch COEP to credentialless if you cannot.

Conclusion

Workers and worklets serve distinct concurrency needs. Workers provide general-purpose parallelism with message-passing isolation — safe by default, with SharedArrayBuffer available when true shared memory is required. Worklets integrate into the rendering pipeline for cases where asynchronous communication cannot meet timing requirements; in 2026 the only worklet that has fully delivered on that promise is Audio Worklet, with Paint Worklet shipping in Chromium and Animation Worklet effectively replaced by CSS Scroll-driven Animations.

For most applications, Dedicated Workers with transferables provide the best balance of simplicity and performance. Reach for SAB only when lock-free shared memory is the actual bottleneck, and for worklets only when the work has to participate in a specific pipeline stage.

Appendix

Prerequisites

  • JavaScript event loop and asynchronous execution model.
  • Basic understanding of the browser rendering pipeline (style → layout → paint → composite).
  • Familiarity with Promises and async/await.
  • For SharedArrayBuffer: shared-memory concurrency concepts (visibility, ordering, atomicity).

Terminology

  • Structured Clone Algorithm: the serialization mechanism used by postMessage() and structuredClone() to copy JavaScript values between contexts.
  • Transferable: an object whose ownership can be moved (not copied) between contexts via postMessage().
  • Render quantum: the fixed block size (128 sample-frames in Web Audio v1) processed per Audio Worklet callback.
  • Cross-origin isolation: the security mode requiring COOP and COEP headers that enables SharedArrayBuffer and high-resolution timing.
  • Agent / agent cluster: the JavaScript spec term for an execution context. Workers are separate agents; SAB-sharing requires being in the same agent cluster.

Summary

  • Dedicated Workers are 1:1 with explicit terminate() control.
  • Shared Workers serve multiple tabs over MessagePorts and survive page reloads within the same origin.
  • Service Workers run under browser control for network interception, push, and background sync; persist caches/IndexedDB, not in-memory state.
  • Module workers (type: 'module') enable ES module syntax; classic workers use importScripts().
  • Structured cloning copies data; transferables move ownership for zero-copy performance.
  • SharedArrayBuffer requires cross-origin isolation (COOP same-origin + COEP require-corp or credentialless) due to Spectre mitigations.
  • Paint Worklets generate custom CSS images synchronously during paint; Chromium-only as of 2026.
  • Animation Worklet never shipped to stable; use CSS Scroll-driven Animations instead.
  • Audio Worklets process samples in 128-sample blocks with sub-3 ms timing requirements; the only Houdini API with full cross-browser support.

References