20 slides · Covers dedicated, shared & service workers with live code examples, SVG diagrams & best practices.
JavaScript runs on a single main thread. Every task — DOM updates, event handlers, network callbacks, layout, painting — shares that one thread.
To hit 60 fps, each frame must complete in ~16.6ms. A heavy computation blocks the entire UI — scroll jank, frozen inputs, dropped frames.
// Blocks UI for seconds!
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(45); // main thread frozen
Web Workers run JavaScript in background threads, separate from the main UI thread. They communicate via message passing and cannot access the DOM directly.
| Type | Scope | Lifetime | Use Case |
|---|---|---|---|
| Dedicated Worker | Single page | While page is open | Heavy computation, parsing, image processing |
| Shared Worker | Multiple pages (same origin) | While any tab is open | Shared state across tabs, WebSocket pooling |
| Service Worker | Origin-wide proxy | Event-driven (outlives tabs) | Offline caching, push notifications, background sync |
All modern browsers. Dedicated Workers since IE10. Service Workers since 2018 across all major engines.
Each worker gets its own event loop, global scope (self), and JS execution context. No shared memory by default.
Workers obey same-origin policy. Service workers require HTTPS (except localhost). No eval() from strings in some contexts.
// Create a dedicated worker
const worker = new Worker('worker.js');
// Send data to the worker
worker.postMessage({
task: 'fibonacci',
value: 45
});
// Receive results back
worker.onmessage = (event) => {
console.log('Result:', event.data);
// UI remains responsive!
};
// Handle errors
worker.onerror = (err) => {
console.error('Worker error:', err.message);
};
// Terminate when done
worker.terminate();
// Worker has its own global scope: `self`
self.onmessage = function(event) {
const { task, value } = event.data;
if (task === 'fibonacci') {
const result = fibonacci(value);
// Send result back to main thread
self.postMessage(result);
}
};
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Workers can also close themselves
// self.close();
const blob = new Blob([`
self.onmessage = (e) => {
self.postMessage(e.data * 2);
};
`], { type: 'application/javascript' });
const w = new Worker(URL.createObjectURL(blob));
Data sent via postMessage is deep-copied using the structured clone algorithm. Supports objects, arrays, Maps, Sets, Dates, Blobs, ArrayBuffers, and more.
// Structured clone — data is COPIED
worker.postMessage({
pixels: new Uint8Array(4_000_000),
metadata: { width: 1000, height: 1000 }
});
// Both threads have separate copies
Zero-copy transfer of ownership. The sending context loses access to the data. Orders of magnitude faster for large buffers.
// Transfer — data is MOVED (zero-copy)
const buffer = new ArrayBuffer(32_000_000);
worker.postMessage(buffer, [buffer]);
// buffer.byteLength === 0 now!
// Transfer multiple objects
const ab1 = new ArrayBuffer(1024);
const ab2 = new ArrayBuffer(2048);
worker.postMessage(
{ a: ab1, b: ab2 },
[ab1, ab2] // transfer list
);
ArrayBuffer — raw binary dataMessagePort — channel endpointsOffscreenCanvas — canvas renderingImageBitmap — decoded imagesReadableStream / WritableStream| Available in Workers | NOT Available |
|---|---|
fetch(), XMLHttpRequest | document, window |
setTimeout / setInterval | DOM APIs (querySelector, etc.) |
IndexedDB | localStorage / sessionStorage |
WebSocket | alert() / confirm() |
crypto, TextEncoder | document.cookie |
importScripts() | Parent scope variables |
navigator (subset) | window.getComputedStyle |
Cache API | Any rendering / layout APIs |
// Classic workers: sync imports
importScripts(
'utils.js',
'parser.js'
);
// Module workers (modern)
const w = new Worker('worker.js', {
type: 'module'
});
// Inside worker.js:
import { parse } from './parser.js';
import _ from 'https://esm.sh/lodash';
self refers to the worker's own global. Key properties: self.name, self.location, self.navigator, self.performance.
// worker-image.js
self.onmessage = ({ data }) => {
const { pixels, width, height } = data;
const d = new Uint8ClampedArray(pixels);
// Grayscale filter
for (let i = 0; i < d.length; i += 4) {
const avg = (d[i] + d[i+1] + d[i+2]) / 3;
d[i] = d[i+1] = d[i+2] = avg;
}
self.postMessage(
d.buffer, [d.buffer] // transfer
);
};
// worker-csv.js
self.onmessage = ({ data }) => {
const rows = data.csv.split('\n');
const headers = rows[0].split(',');
const records = [];
for (let i = 1; i < rows.length; i++) {
const vals = rows[i].split(',');
const obj = {};
headers.forEach((h, j) => {
obj[h.trim()] = vals[j]?.trim();
});
records.push(obj);
}
self.postMessage({ records });
};
// worker-crypto.js
self.onmessage = async ({ data }) => {
const encoder = new TextEncoder();
const msgBuf = encoder.encode(data.text);
// SHA-256 hashing
const hashBuf = await crypto.subtle
.digest('SHA-256', msgBuf);
const hashArr = Array.from(
new Uint8Array(hashBuf)
);
const hex = hashArr
.map(b => b.toString(16).padStart(2,'0'))
.join('');
self.postMessage({ hash: hex });
};
// Worker sends periodic progress updates
for (let i = 0; i < total; i++) {
processItem(items[i]);
if (i % 1000 === 0) self.postMessage({ type: 'progress', done: i, total });
}
self.postMessage({ type: 'complete', result: output });
A SharedWorker is shared across all pages/tabs/iframes of the same origin. Each connection gets a unique MessagePort.
// page-a.js (Tab 1) and page-b.js (Tab 2)
const shared = new SharedWorker('shared.js');
const port = shared.port;
port.onmessage = (e) => {
console.log('Received:', e.data);
};
port.postMessage({ user: 'Tab1', action: 'join' });
port.start(); // required if using addEventListener
// shared.js
const ports = [];
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
port.onmessage = (e) => {
// Broadcast to all connected tabs
ports.forEach(p => {
p.postMessage({
from: e.data.user,
msg: e.data.action,
connections: ports.length
});
});
};
};
install — Pre-cache critical assetsactivate — Clean up old cache versionsfetch — Intercept network requestspush — Receive push messages from serversync — Background sync when connectivity returnsmessage — Communicate with controlled pages// main.js — register on page load
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register(
'/sw.js',
{ scope: '/' } // controls which URLs it intercepts
);
console.log('SW registered, scope:', reg.scope);
// Check for updates
reg.addEventListener('updatefound', () => {
const newSW = reg.installing;
newSW.addEventListener('statechange', () => {
if (newSW.state === 'activated') {
// New version ready — prompt reload?
showUpdateBanner();
}
});
});
} catch (err) {
console.error('SW registration failed:', err);
}
});
}
// sw.js
const CACHE_NAME = 'app-v2';
self.addEventListener('install', (event) => {
console.log('SW installing...');
// Skip waiting to activate immediately
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('SW activating...');
// Claim all open clients
event.waitUntil(
clients.claim()
);
});
self.addEventListener('fetch', (event) => {
// Intercept every network request
event.respondWith(
handleFetch(event.request)
);
});
A SW at /app/sw.js can only control URLs under /app/. Place sw.js at the root for full-site control, or use the Service-Worker-Allowed header.
The Cache API provides a persistent key-value store where keys are Request objects and values are Response objects. Independent of HTTP cache.
// Pre-cache during install
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('static-v1').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
'/logo.png',
'/offline.html'
]);
})
);
});
// Clean old caches during activate
self.addEventListener('activate', (event) => {
const keep = ['static-v2', 'dynamic-v1'];
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => !keep.includes(k))
.map(k => caches.delete(k))
)
)
);
});
| Method | Description |
|---|---|
caches.open(name) | Open/create a named cache |
cache.add(url) | Fetch & store a response |
cache.addAll(urls) | Fetch & store multiple |
cache.put(req, res) | Manually store a pair |
cache.match(req) | Find matching response |
cache.delete(req) | Remove a cached item |
cache.keys() | List all cached requests |
// Dynamic caching during fetch
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
const clone = response.clone();
caches.open('dynamic-v1').then(cache => {
cache.put(event.request, clone);
});
return response;
});
})
);
});
Best for: static assets, fonts, images. Fast loads from cache; network only if cache miss.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open('static-v1');
cache.put(request, response.clone());
return response;
}
Best for: API calls, frequently updated content. Fresh data when online; cached fallback offline.
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open('dynamic-v1');
cache.put(request, response.clone());
return response;
} catch (err) {
return caches.match(request);
}
}
Best for: semi-dynamic content (avatars, articles). Instant response from cache + background update.
async function staleWhileRevalidate(request) {
const cache = await caches.open('swr-v1');
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(res => {
cache.put(request, res.clone());
return res;
});
return cached || fetchPromise;
}
Cache Only: pre-cached assets that never change. Network Only: non-GET requests, analytics pings.
// Cache only
const cacheOnly = (req) => caches.match(req);
// Network only
const networkOnly = (req) => fetch(req);
Pre-cache the minimal HTML, CSS, JS "shell" at install time. Dynamic content loaded separately. Shell renders instantly even offline.
// sw.js — App Shell caching
const SHELL_ASSETS = [
'/shell.html',
'/css/app.css',
'/js/app.js',
'/js/router.js',
'/images/logo.svg'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open('shell-v3')
.then(c => c.addAll(SHELL_ASSETS))
);
});
// Navigate requests → shell
self.addEventListener('fetch', (e) => {
if (e.request.mode === 'navigate') {
e.respondWith(
caches.match('/shell.html')
);
return;
}
// Other requests: strategy per type...
});
Queue operations when offline, replay when connectivity returns. User never sees a failure.
// Register a sync event
async function saveComment(data) {
// Save to IndexedDB first
await db.comments.add(data);
// Request background sync
const reg = await navigator
.serviceWorker.ready;
await reg.sync.register('sync-comments');
}
// sw.js — handle sync
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-comments') {
event.waitUntil(
pushPendingComments()
);
}
});
// Serve offline page for nav failures
e.respondWith(
fetch(e.request).catch(() =>
caches.match('/offline.html')
)
);
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// Subscribe to push
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleNotification: true,
applicationServerKey: urlBase64ToUint8Array(
'BEl62iUYgUivxIkv69yViE...' // VAPID public key
)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
Voluntary Application Server Identification. A key pair that identifies your server to the push service. Generate with web-push generate-vapid-keys.
// sw.js — receive push event
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const options = {
body: data.body || 'New notification',
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
image: data.image,
vibrate: [200, 100, 200],
tag: data.tag || 'default', // group
renotify: true,
actions: [
{ action: 'open', title: 'Open App' },
{ action: 'dismiss', title: 'Dismiss' }
],
data: { url: data.url || '/' }
};
event.waitUntil(
self.registration.showNotification(
data.title || 'App Update', options
)
);
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
if (e.action === 'open' || !e.action) {
e.waitUntil(
clients.openWindow(e.notification.data.url)
);
}
});
{
"name": "My Awesome App",
"short_name": "AwesomeApp",
"description": "A full-featured PWA",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#f5a623",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
<!-- Link in HTML head -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#f5a623">
start_url is cacheable / works offlinelet deferredPrompt;
window.addEventListener(
'beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
}
);
installBtn.onclick = async () => {
deferredPrompt.prompt();
const { outcome } = await
deferredPrompt.userChoice;
console.log(outcome); // 'accepted'
};
fullscreen · standalone · minimal-ui · browser
Workbox provides production-ready modules for common service worker patterns — routing, caching strategies, precaching, background sync, and more.
// sw.js — import Workbox from CDN
importScripts(
'https://storage.googleapis.com/workbox-cdn' +
'/releases/7.0.0/workbox-sw.js'
);
const { registerRoute } = workbox.routing;
const { CacheFirst, StaleWhileRevalidate, NetworkFirst }
= workbox.strategies;
const { precacheAndRoute } = workbox.precaching;
const { ExpirationPlugin } = workbox.expiration;
// Precache app shell (generated by build tool)
precacheAndRoute(self.__WB_MANIFEST || []);
// Cache images: cache-first, max 60, 30 days
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
})
);
// CSS/JS: stale-while-revalidate
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);
// API calls: network-first with timeout
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3,
})
);
// Navigation: network-first with offline fallback
import { NavigationRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
const navHandler = new NetworkFirst({
cacheName: 'navigations',
});
registerRoute(new NavigationRoute(navHandler));
workbox-cli, workbox-webpack-plugin, and workbox-build generate the precache manifest automatically at build time.
chrome://inspect/#service-workers — all registered SWschrome://serviceworker-internals — detailed internal state// Conditional logging for workers
const DEBUG = true;
const log = (...args) => {
if (DEBUG) console.log('[SW]', ...args);
};
self.addEventListener('fetch', (e) => {
log('Fetch:', e.request.url);
// ...
});
// Workers appear in Sources panel
// under "Threads" section
// Use console.log in worker
self.onmessage = (e) => {
console.log('Worker received:', e.data);
console.time('processing');
// ... work ...
console.timeEnd('processing');
};
reg.scope vs expectedevent.waitUntil() not used — SW killed mid-operationresponse.clone())Spin up N workers to parallelize work across CPU cores.
class WorkerPool {
constructor(script, size = navigator.hardwareConcurrency) {
this.workers = Array.from({ length: size },
() => new Worker(script));
this.queue = [];
this.free = [...this.workers];
}
exec(data) {
return new Promise((resolve) => {
const task = { data, resolve };
const w = this.free.pop();
if (w) this._run(w, task);
else this.queue.push(task);
});
}
_run(worker, task) {
worker.onmessage = (e) => {
task.resolve(e.data);
const next = this.queue.shift();
if (next) this._run(worker, next);
else this.free.push(worker);
};
worker.postMessage(task.data);
}
}
Google's library wraps postMessage with Proxy-based RPC. Call worker functions as if they were local async functions.
// worker.js
import * as Comlink from 'comlink';
const api = {
fibonacci(n) {
if (n <= 1) return n;
return api.fibonacci(n-1) + api.fibonacci(n-2);
},
async processImage(data) { /* ... */ }
};
Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';
const api = Comlink.wrap(
new Worker('worker.js', { type: 'module' })
);
const result = await api.fibonacci(42); // magic!
Render canvas graphics in a worker — no main thread cost.
// main.js
const canvas = document.getElementById('c');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// worker.js — render loop off main thread
self.onmessage = ({ data }) => {
const ctx = data.canvas.getContext('2d');
function draw() { /* ... */ requestAnimationFrame(draw); }
draw();
};
True shared memory between threads. Requires Cross-Origin-Isolation headers. Use Atomics for synchronization.
// Shared memory between main + worker
const sab = new SharedArrayBuffer(1024);
const arr = new Int32Array(sab);
worker.postMessage({ buffer: sab });
// Worker: Atomics for thread-safe ops
Atomics.add(arr, 0, 1);
Atomics.wait(arr, 0, expectedVal);
Atomics.notify(arr, 0);
postMessageWorkers Service Workers Cache API PWA