JavaScript is single-threaded. There is one call stack, one thread of execution, and at any given moment exactly one thing is running. Yet it handles timers, network requests, user events, animations, background computation, and offline support without blocking. Understanding how that actually works – not just that it works – changes how you write async code, debug race conditions, and reason about performance. This post covers the full picture: the event loop, the queues, Web Workers, and Service Workers.
The Moving Parts
Four components interact to make async JavaScript work on the main thread.
The call stack
The call stack is where synchronous code executes. When you call a function, a frame is pushed onto the stack. When it returns, the frame is popped. The engine can only execute code at the top of the stack. If a function takes 500ms of CPU work, nothing else can happen during those 500ms – the stack is blocked. This is why long-running synchronous operations freeze the browser: the event loop cannot process anything while the stack is occupied.
Web APIs (or Node.js equivalents)
The browser provides APIs that live outside the JavaScript engine: timers, network I/O, DOM events, file system access. When you call setTimeout, the JS engine does not wait – it hands the callback and delay to the browser’s timer mechanism and immediately returns. The actual waiting happens in a separate system thread managed by the browser. When the timer fires, the browser posts the callback into a queue.
This is the key insight: JavaScript itself never does I/O. It delegates to the runtime, registers a callback, and moves on. The runtime notifies JavaScript when the work is done by placing callbacks into one of two queues.
The microtask queue
Microtasks are high-priority callbacks that run immediately after the current task completes – before the event loop picks up any macrotask, and before the browser renders the next frame. Sources of microtasks include:
Promise.then(),.catch(),.finally()queueMicrotask()MutationObservercallbacksawaitcontinuations (which desugar to Promise.then())
The critical rule: the microtask queue drains completely before the event loop moves on. If a microtask callback queues another microtask, that microtask also runs before any macrotask. This can theoretically starve the macrotask queue – and the rendering pipeline – if microtasks keep spawning more microtasks.
The macrotask queue (task queue)
Macrotasks are lower-priority callbacks scheduled by the runtime. Sources include:
setTimeoutandsetIntervalcallbacks- I/O callbacks (file reads, network in Node.js)
- UI event callbacks (click, keydown, scroll)
setImmediatein Node.jsMessageChannelport messages
The event loop picks exactly one macrotask per iteration. After that one macrotask runs, it drains the entire microtask queue again before picking the next macrotask.
The event loop algorithm
while (true) {
// 1. Execute the current task (the script itself on first run)
executeCurrentTask();
// 2. Drain the entire microtask queue
while (microtaskQueue.length > 0) {
const task = microtaskQueue.shift();
task();
// if task() enqueues more microtasks, they run here too
}
// 3. Optionally render (if the browser decides this frame needs painting)
maybeRender();
// 4. Pick the oldest macrotask (if any)
if (macrotaskQueue.length > 0) {
const task = macrotaskQueue.shift();
task();
}
}
Why Promise.then() always beats setTimeout(fn, 0)
setTimeout(fn, 0) does not mean “run immediately”. It means “run as soon as possible, but only after the current task and all microtasks have finished, and only as a macrotask”. A Promise.then() registered at the same time will always run first because it goes into the microtask queue, which drains before any macrotask is picked.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
// Output:
// promise (microtask - runs first)
// timeout (macrotask - runs after all microtasks)
How fetch actually works
When you call fetch('/data'):
fetch()returns aPromiseimmediately. The browser’s networking layer starts the HTTP request in a separate thread.- JavaScript continues executing synchronously – nothing waits.
- When the response arrives, the browser resolves the promise, queuing any
.then()handlers as microtasks. - The next time the microtask queue drains, those handlers run.
- Each
.then()in a chain is a separate microtask, queued lazily when the previous one resolves.
How async/await desugars
async/await is syntactic sugar over promises. Every await suspends the async function (pops its frame off the call stack) and queues the continuation as a microtask when the awaited promise resolves.
// async/await
async function load() {
const res = await fetch('/data');
const data = await res.json();
console.log(data);
}
// Is approximately:
function load() {
return fetch('/data')
.then(res => res.json())
.then(data => { console.log(data); });
}
A worked example: what does this log?
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
console.log('E');
// Output: A E C D B
Sync runs first: A, then E. Microtask queue drains: C resolves and queues D, so C then D. Finally the macrotask runs: B. The second .then() was not enqueued until the first one ran – promise chains are lazy microtask sequences, not a pre-loaded batch.
Web Workers: true parallelism for JavaScript
The event loop solves the problem of async waiting – delegating I/O to the browser and resuming when ready. But it does not solve CPU-intensive computation. If you need to parse a 50MB JSON file, run a physics simulation, encrypt a large payload, or process an image, doing that on the main thread blocks the call stack and freezes the UI regardless of how cleverly you structure your promises.
Web Workers give you a genuine second OS thread. A Worker runs in a completely separate execution context with its own call stack, its own microtask and macrotask queues, its own memory heap, and its own event loop. The main thread and worker run in parallel – truly simultaneously on multi-core hardware.
What Workers can and cannot do
A Worker has access to: fetch, setTimeout, setInterval, Promise, IndexedDB, WebSockets, Cache API, crypto, console, and most Web APIs that don’t require a rendering context. What it does not have access to is the DOM – no document, no window, no direct manipulation of page elements. The rendering pipeline lives exclusively on the main thread, and the browser enforces this hard boundary.
Communication via postMessage
The main thread and workers communicate exclusively through postMessage(). Data is serialised (structured clone algorithm) and deserialised on the other side – each side gets its own copy, not a shared reference. For large data (images, audio buffers, typed arrays) you can use Transferable Objects to hand ownership to the other thread without copying, which is zero-copy and fast.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ action: 'process', data: largeArray });
worker.onmessage = (event) => {
// runs as a macrotask on the main thread when the worker responds
console.log('Result:', event.data);
};
// worker.js
self.onmessage = (event) => {
// runs in the worker's own event loop
const result = heavyComputation(event.data.data);
self.postMessage(result); // sends result back to main thread
};
When a worker calls postMessage(), the message arrives on the main thread as a macrotask – it queues a message event on the worker object. The main thread’s event loop picks it up in the normal way: after all current microtasks have drained. This means worker communication is non-blocking in both directions.
Transferable Objects for zero-copy transfer
// Transfer an ArrayBuffer without copying (ownership moves to worker)
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB
worker.postMessage({ buffer }, [buffer]);
// buffer is now detached in main - worker owns it
// Worker transfers it back when done
self.onmessage = (e) => {
processBuffer(e.data.buffer);
self.postMessage({ buffer: e.data.buffer }, [e.data.buffer]);
};
Shared memory with SharedArrayBuffer
For high-frequency communication (game engines, audio processing, real-time data pipelines), copying data on every message is too expensive. SharedArrayBuffer creates a memory region that both the main thread and workers can read and write without copying. You coordinate access using Atomics – atomic operations that prevent race conditions by guaranteeing visibility and mutual exclusion.
// Shared memory visible to both threads simultaneously
const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
// Worker can read and write view[0] directly
worker.postMessage({ shared });
// Atomics.wait lets worker sleep until main signals
// Atomics.notify wakes the worker
Atomics.store(view, 0, 1); // write atomically
Atomics.notify(view, 0, 1); // wake one waiting worker
Worker types
There are three kinds of Worker: Dedicated Workers (owned by one page, created with new Worker()), Shared Workers (shared across multiple pages from the same origin, accessible via new SharedWorker()), and Service Workers (covered in the next section). Dedicated Workers are by far the most common.
Service Workers: the network proxy
A Service Worker is a type of worker with a fundamentally different purpose. Where a Dedicated Worker offloads CPU computation, a Service Worker sits between your application and the network, intercepting and handling every fetch request the page makes. It is the foundation of Progressive Web Apps (PWAs) – offline support, background sync, push notifications, and fine-grained caching strategies all live here.
The Service Worker lifecycle
A Service Worker has a distinct lifecycle that separates it from ordinary workers:
- Registration – the page calls
navigator.serviceWorker.register('/sw.js'). The browser downloads and parses the worker script. - Install – the browser fires the
installevent. This is where you pre-cache static assets. You callevent.waitUntil(promise)to tell the browser not to proceed until your caching is complete. If the promise rejects, the install fails and the worker is discarded. - Activate – once installed, the worker waits to activate. A new worker only activates when no existing controlled pages are open (or when you call
self.skipWaiting()). Theactivateevent is where you clean up old caches from previous versions. - Idle – the worker is now active and controlling pages, but the browser may terminate it at any time to save memory. It is relaunched on demand when a controlled page makes a fetch or a push notification arrives.
// sw.js
const CACHE = 'v1';
const STATIC = ['/index.html', '/app.js', '/style.css'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE).then(cache => cache.addAll(STATIC))
);
self.skipWaiting(); // activate immediately, don't wait for old tabs to close
});
self.addEventListener('activate', event => {
event.waitUntil(
// Delete old cache versions
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim(); // take control of all open pages immediately
});
Intercepting fetch requests
The fetch event is the core of a Service Worker. Every network request made by a controlled page – including fetch(), XMLHttpRequest, CSS imports, image loads, script tags – passes through this handler. You use event.respondWith(promise) to provide the response, which can come from the cache, the network, or be constructed programmatically.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
// Cache-first strategy: serve from cache, fall back to network
if (cached) return cached;
return fetch(event.request).then(response => {
// Clone the response - one to cache, one to return
const toCache = response.clone();
caches.open(CACHE).then(cache => cache.put(event.request, toCache));
return response;
});
})
);
});
Caching strategies
The fetch handler is where you implement your caching strategy. Common patterns are:
- Cache first – serve from cache if present, otherwise fetch from network and cache the result. Best for assets that change infrequently (fonts, versioned JS bundles).
- Network first – try the network, fall back to cache if offline. Best for frequently updated content (API responses, news feeds).
- Stale while revalidate – serve from cache immediately (fast), then fetch from network in the background to update the cache for the next request. Best for content where showing slightly stale data is acceptable.
- Network only – never use the cache. For analytics, payment flows, anything where stale data is dangerous.
- Cache only – never hit the network. For pre-cached static assets in a fully offline app.
Background sync and push notifications
Service Workers can be woken up by the browser even when no page is open. The sync event fires when connectivity is restored after a period offline – you can queue writes made while offline and flush them here. The push event fires when a push message arrives from your server, allowing you to display a notification even if the user doesn’t have your site open.
// Background sync - retry failed requests when back online
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(flushPendingSubmissions());
}
});
// Push notification - received even when app is closed
self.addEventListener('push', event => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png',
})
);
});
Communicating with the page
A Service Worker communicates with its controlled pages via postMessage, just like a Dedicated Worker. The worker can send messages to all controlled clients via self.clients.matchAll(), and individual pages can send messages to the worker via navigator.serviceWorker.controller.postMessage(). As with Dedicated Workers, these messages arrive as macrotasks on the receiving end.
// Service Worker broadcasting to all pages
self.clients.matchAll().then(clients => {
clients.forEach(client => client.postMessage({ type: 'CACHE_UPDATED' }));
});
// Page listening for messages from the Service Worker
navigator.serviceWorker.addEventListener('message', event => {
if (event.data.type === 'CACHE_UPDATED') {
showUpdateBanner();
}
});
Web Worker vs Service Worker: when to use which
The two are often confused because both are “workers that run off the main thread”. The distinction is purpose, not mechanism.
| Web Worker | Service Worker | |
|---|---|---|
| Primary purpose | CPU-intensive computation | Network proxy and caching |
| Lifetime | As long as the page holds a reference | Persists independently, browser controls termination |
| Scope | Single page | All pages on the same origin |
| fetch access | Can make fetch calls | Intercepts all fetch calls from the page |
| DOM access | No | No |
| Offline support | No | Yes, via Cache API |
| Push notifications | No | Yes |
| Background sync | No | Yes |
| Typical use cases | Image processing, data parsing, encryption, physics, ML inference | Caching strategy, offline PWA, push, background sync |
Practical implications
Long microtask chains can block rendering
The browser cannot render a new frame until the microtask queue is empty. For large data processing, break work into chunks using setTimeout to yield to the rendering pipeline, or use a Web Worker to move the work off the main thread entirely.
Service Workers require HTTPS
Service Workers can intercept and modify any network request, which makes them powerful – and dangerous if compromised. Browsers only register Service Workers on HTTPS origins (and localhost for development). There are no exceptions.
Service Workers are version-sensitive
When you deploy a new Service Worker, users with the old version keep it until all their tabs are closed and reopened. skipWaiting() + clients.claim() force immediate takeover – useful during development but potentially disruptive if the new worker uses an incompatible cache structure. Design your activate handler to clean up old caches explicitly.
Workers have their own event loops
Both Web Workers and Service Workers have their own complete event loop – their own call stack, microtask queue, and macrotask queue. Promise, setTimeout, fetch, and async/await all work inside a worker the same way they work on the main thread. The only difference is the absence of the DOM and window APIs.
Summary
JavaScript’s concurrency model has three layers. The event loop handles async waiting on the main thread – sync code runs first, then all microtasks drain (Promises, await continuations), then one macrotask runs, then microtasks drain again. Web Workers handle CPU parallelism – genuine OS threads running their own event loops, communicating with the main thread via postMessage without blocking the UI. Service Workers handle network and persistence – a long-lived proxy thread that intercepts fetch requests, implements caching strategies, enables offline use, and receives push notifications and background sync events independently of any open page.
Together they give JavaScript – a language with one main thread – a complete answer to async I/O, CPU parallelism, and network resilience.
Leave a Reply