Workers
Web Workers, Service Workers, SharedWorker, and Worklets — off-main-thread computation, offline support, and background processing
Workers
Worker APIs allow JavaScript to run in background threads, separate from the main UI thread. This prevents heavy computation from blocking user interactions and enables offline-capable, resilient web applications.
Problems Solved
| Problem | How Workers Solve It |
|---|---|
| Heavy computation blocks UI (jank) | Web Workers run in separate thread |
| No offline support in web apps | Service Workers cache and serve assets |
| No shared state between tabs | SharedWorker shares a single worker across tabs |
| Audio/visual processing blocks main thread | Worklets run on specialized threads |
| Push notifications require app open | Service Workers receive push events in background |
Worker Types
Browser Process
├── Main Thread
│ ├── DOM rendering
│ ├── Event handling
│ └── JavaScript execution
│
├── Web Worker Thread (per worker)
│ └── Heavy computation, data processing
│
├── SharedWorker Thread (shared across tabs)
│ └── Cross-tab state, connection pooling
│
├── Service Worker Thread (per scope)
│ └── Network interception, caching, push
│
└── Worklet Threads (specialized)
├── AudioWorklet → Real-time audio processing
├── PaintWorklet → Custom CSS painting
└── AnimationWorklet → Smooth animationsWeb Workers
Basic Usage
// main.js
const worker = new Worker('worker.js');
// Send data to worker
worker.postMessage({ type: 'process', data: largeArray });
// Receive results
worker.onmessage = (event) => {
console.log('Result:', event.data);
};
worker.onerror = (event) => {
console.error('Worker error:', event.message);
};
// Terminate when done
worker.terminate();// worker.js
self.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'process') {
// Heavy computation runs here without blocking UI
const result = data.map(item => expensiveOperation(item));
self.postMessage(result);
}
};Inline Worker (No Separate File)
function createInlineWorker(fn) {
const blob = new Blob(
[`self.onmessage = function(e) { (${fn.toString()})(e); }`],
{ type: 'application/javascript' }
);
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
// Clean up blob URL when worker is terminated
const originalTerminate = worker.terminate.bind(worker);
worker.terminate = () => {
URL.revokeObjectURL(url);
originalTerminate();
};
return worker;
}
const worker = createInlineWorker((e) => {
const result = e.data.reduce((sum, n) => sum + n, 0);
self.postMessage(result);
});
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (e) => console.log('Sum:', e.data); // 15Transferable Objects
Transfer ownership of data instead of copying — zero-copy for large buffers:
// main.js — Transfer an ArrayBuffer (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
console.log(buffer.byteLength); // 104857600
// Transfer moves the buffer to the worker; main thread loses access
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — ownership transferred
// worker.js
self.onmessage = (e) => {
const { buffer } = e.data;
const view = new Float64Array(buffer);
// Process the data...
self.postMessage({ buffer }, [buffer]); // Transfer back
};Transferable Types
| Type | Transferable | Structured Clone |
|---|---|---|
| ArrayBuffer | Yes | Yes |
| MessagePort | Yes | No |
| ReadableStream | Yes | No |
| WritableStream | Yes | No |
| ImageBitmap | Yes | No |
| OffscreenCanvas | Yes | No |
| Plain objects/arrays | No | Yes |
| Functions | No | No |
| DOM nodes | No | No |
Worker Pool
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(workerUrl));
this.queue = [];
this.activeWorkers = new Set();
}
exec(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
const available = this.workers.find(w => !this.activeWorkers.has(w));
if (available) {
this._dispatch(available, task);
} else {
this.queue.push(task);
}
});
}
_dispatch(worker, task) {
this.activeWorkers.add(worker);
worker.onmessage = (e) => {
this.activeWorkers.delete(worker);
task.resolve(e.data);
// Process next item in queue
if (this.queue.length > 0) {
this._dispatch(worker, this.queue.shift());
}
};
worker.onerror = (e) => {
this.activeWorkers.delete(worker);
task.reject(e);
};
worker.postMessage(task.data);
}
terminate() {
this.workers.forEach(w => w.terminate());
}
}
// Usage
const pool = new WorkerPool('image-processor.js', 4);
const results = await Promise.all(
images.map(img => pool.exec({ image: img, operation: 'resize' }))
);Use Cases
| Use Case | Example |
|---|---|
| Data processing | CSV parsing, JSON transformation, sorting |
| Image manipulation | Filters, resizing, format conversion |
| Encryption | Hashing, encryption/decryption |
| Search/indexing | Full-text search, fuzzy matching |
| Compilation | Markdown rendering, code compilation |
| Simulation | Physics, pathfinding, game logic |
SharedWorker
A SharedWorker is shared across all tabs/windows from the same origin. Each page connects through a MessagePort.
// shared-worker.js
const connections = new Set();
self.onconnect = (e) => {
const port = e.ports[0];
connections.add(port);
port.onmessage = (event) => {
// Broadcast to all connected tabs
connections.forEach(p => {
if (p !== port) {
p.postMessage(event.data);
}
});
};
port.onclose = () => connections.delete(port);
port.start();
};// page.js (any tab)
const worker = new SharedWorker('shared-worker.js');
const port = worker.port;
port.onmessage = (e) => {
console.log('Message from another tab:', e.data);
};
port.postMessage({ action: 'sync', data: 'Hello from this tab' });SharedWorker Use Cases
- Cross-tab state synchronization — shared shopping cart, user presence
- Single WebSocket connection — one connection shared across tabs
- Connection pooling — share database or API connections
- Deduplication — prevent duplicate API calls from multiple tabs
Service Workers
Service Workers act as a programmable network proxy between the browser and network. They enable offline support, background sync, and push notifications.
Lifecycle
Service Worker Lifecycle
1. Register navigator.serviceWorker.register('/sw.js')
2. Install Cache essential assets
3. Activate Clean up old caches
4. Idle / Fetch Intercept network requests
5. Terminate Browser can stop idle workers to save memory
6. Update New SW version detected → install → activateRegistration
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => {
console.log('SW registered:', reg.scope);
// Listen for updates
reg.onupdatefound = () => {
const newWorker = reg.installing;
newWorker.onstatechange = () => {
if (newWorker.state === 'activated') {
console.log('New version activated');
}
};
};
})
.catch(err => console.error('SW registration failed:', err));
}Caching Strategies
// sw.js
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = ['/', '/styles.css', '/app.js', '/offline.html'];
// Install: precache essential assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// Fetch: serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
// Cache successful GET responses
if (response.ok && event.request.method === 'GET') {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => {
// Offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});Common Caching Strategies
| Strategy | How It Works | Best For |
|---|---|---|
| Cache First | Check cache → fall back to network | Static assets (CSS, JS, images) |
| Network First | Try network → fall back to cache | API responses, frequently updated content |
| Stale While Revalidate | Serve cache immediately → update cache in background | Balance of speed and freshness |
| Network Only | Always fetch from network | Non-cacheable requests, real-time data |
| Cache Only | Only serve from cache | Precached assets in offline mode |
Stale While Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then(cache =>
cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
})
)
);
});Background Sync
// main.js — register sync when offline
async function saveData(data) {
try {
await fetch('/api/data', { method: 'POST', body: JSON.stringify(data) });
} catch {
// Save to IndexedDB for later
await saveToIndexedDB('pending-syncs', data);
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-data');
}
}
// sw.js — process when back online
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(
getFromIndexedDB('pending-syncs').then(items =>
Promise.all(items.map(item =>
fetch('/api/data', { method: 'POST', body: JSON.stringify(item) })
))
).then(() => clearIndexedDB('pending-syncs'))
);
}
});Push Notifications
// main.js — subscribe to push
async function subscribeToPush() {
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
// sw.js — receive push
self.addEventListener('push', (event) => {
const data = event.data?.json() || {};
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', {
body: data.body,
icon: '/icon-192.png',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(clients.openWindow(url));
});Worklets
Worklets are lightweight workers that run on specialized browser threads for audio, paint, and animation processing.
Paint Worklet (CSS Houdini)
// checkerboard-painter.js
class CheckerboardPainter {
static get inputProperties() {
return ['--checker-size', '--checker-color'];
}
paint(ctx, geom, properties) {
const size = parseInt(properties.get('--checker-size')) || 20;
const color = properties.get('--checker-color').toString().trim() || '#000';
for (let y = 0; y < geom.height; y += size) {
for (let x = 0; x < geom.width; x += size) {
if ((Math.floor(x / size) + Math.floor(y / size)) % 2 === 0) {
ctx.fillStyle = color;
ctx.fillRect(x, y, size, size);
}
}
}
}
}
registerPaint('checkerboard', CheckerboardPainter);// main.js
CSS.paintWorklet.addModule('checkerboard-painter.js');.checkerboard {
--checker-size: 30;
--checker-color: #e2e8f0;
background-image: paint(checkerboard);
}Audio Worklet
// noise-processor.js
class WhiteNoiseProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const output = outputs[0];
output.forEach(channel => {
for (let i = 0; i < channel.length; i++) {
channel[i] = Math.random() * 2 - 1;
}
});
return true; // Keep processor alive
}
}
registerProcessor('white-noise', WhiteNoiseProcessor);// main.js
const audioCtx = new AudioContext();
await audioCtx.audioWorklet.addModule('noise-processor.js');
const noiseNode = new AudioWorkletNode(audioCtx, 'white-noise');
noiseNode.connect(audioCtx.destination);Worker Communication Summary
| Pattern | Mechanism | Direction |
|---|---|---|
| Main ↔ Worker | postMessage / onmessage | Bidirectional |
| Main ↔ SharedWorker | MessagePort | Bidirectional |
| Main → Service Worker | navigator.serviceWorker.controller.postMessage() | One-way |
| Service Worker → Main | client.postMessage() | One-way |
| Worker ↔ Worker | MessageChannel | Bidirectional |
| Any context broadcast | BroadcastChannel | Multicast |
Best Practices
- Use Transferable Objects for large data (ArrayBuffer, ImageBitmap) to avoid copy overhead
- Implement a Worker Pool for CPU-intensive parallel tasks
- Service Workers require HTTPS (except localhost)
- Always handle the
statechangeevent to detect Service Worker updates - Use
skipWaiting()+clients.claim()for immediate activation when appropriate - Clean up old caches in the
activateevent - Keep Service Worker code minimal — the browser may terminate idle workers at any time