Web Workers & Service Workers
JavaScript's single-threaded nature is its biggest constraint. Workers break that constraint â they run JavaScript on separate threads, freeing the main thread for UI work. Service Workers go further, acting as a programmable proxy between your app and the network.
The Threading Model
Main Thread Worker Threads
âââââââââââââââââââ âââââââââââââââââââ
â DOM access â â No DOM access â
â Event handlers â message â Heavy compute â
â UI rendering â âââââââââš â Data processing â
â Layout/Paint â passing â Image/Video â
âââââââââââââââââââ âââââââââââââââââââ
âââââââââââââââââââ
â Service Worker â
â Network proxy â
â Push notifs â
â Background sync â
âââââââââââââââââââWeb Workers (Dedicated Workers)
A Web Worker runs a JavaScript file in a separate thread. Communication happens via postMessage:
Basic Setup
// main.js
const worker = new Worker('/worker.js');
worker.postMessage({ type: 'PROCESS', data: largeDataset });
worker.onmessage = (event) => {
console.log('Result:', event.data);
};
worker.onerror = (error) => {
console.error('Worker error:', error.message);
};
// Terminate when done
worker.terminate();// worker.js
self.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'PROCESS') {
const result = heavyComputation(data);
self.postMessage(result);
}
};
function heavyComputation(data) {
return data
.filter(item => item.score > threshold)
.map(item => ({ ...item, rank: computeRank(item) }))
.sort((a, b) => b.rank - a.rank);
}What Workers CAN'T Do
- Access the DOM (
document,window.document) - Access
windowdirectly (useselfinstead) - Run synchronous XHR (deprecated anyway)
- Access parent's variables or scope
What Workers CAN Do
fetch()â Make network requestsIndexedDBâ Read/write databaseWebSocketâ Maintain connectionscryptoâ Cryptographic operationssetTimeout/setIntervalâ TimersimportScripts()â Load additional scriptspostMessage()â Communicate with main thread
Real-World Use Cases
// 1. Heavy data processing
worker.postMessage({
type: 'SORT_AND_FILTER',
data: tenThousandRows,
filters: activeFilters,
sortBy: 'date',
});
// 2. Image processing
worker.postMessage({
type: 'RESIZE_IMAGE',
imageData: canvas.getImageData(0, 0, width, height),
targetWidth: 200,
});
// 3. Search indexing
worker.postMessage({
type: 'BUILD_INDEX',
documents: allDocuments,
});
// 4. CSV/JSON parsing
worker.postMessage({
type: 'PARSE_CSV',
rawText: csvContent, // 50MB CSV file
});React Integration
function useWorker<TInput, TOutput>(workerUrl: string) {
const workerRef = useRef<Worker | null>(null);
const [result, setResult] = useState<TOutput | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
workerRef.current = new Worker(workerUrl);
workerRef.current.onmessage = (e: MessageEvent<TOutput>) => {
setResult(e.data);
setLoading(false);
};
return () => workerRef.current?.terminate();
}, [workerUrl]);
const run = useCallback((data: TInput) => {
setLoading(true);
workerRef.current?.postMessage(data);
}, []);
return { result, loading, run };
}
// Usage
function DataProcessor({ data }) {
const { result, loading, run } = useWorker<RawData, ProcessedData>('/workers/processor.js');
return (
<div>
<button onClick={() => run(data)} disabled={loading}>
{loading ? 'Processing...' : 'Process Data'}
</button>
{result && <ResultsTable data={result} />}
</div>
);
}Transferable Objects
For large data (ArrayBuffers, ImageData), use transfer instead of copy:
// â Copying â duplicates the data (slow for large buffers)
worker.postMessage({ buffer: largeArrayBuffer });
// â
Transfer â moves ownership to worker (instant, zero-copy)
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
// largeArrayBuffer is now neutered in main thread (length = 0)Transfer is O(1) regardless of data size. Use it for:
- ArrayBuffers (raw binary data)
- MessagePorts
- ImageBitmap
- OffscreenCanvas
Shared Workers
A single worker shared across multiple tabs/windows of the same origin:
// Any tab
const shared = new SharedWorker('/shared-worker.js');
shared.port.start();
shared.port.postMessage('hello from tab');
shared.port.onmessage = (e) => console.log('Received:', e.data);// shared-worker.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(e.data));
};
};Use cases: Cross-tab state sync, shared WebSocket connection, shared cache.
Service Workers
A Service Worker is a programmable network proxy that sits between your app and the network. It's the foundation of Progressive Web Apps (PWAs).
Lifecycle
Register â Install â Activate â Fetch/Push/Sync
â â
Cache assets Clean old caches// Registration (in main app)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW failed:', err));
}// sw.js â Install event (cache critical assets)
const CACHE_NAME = 'v2';
const CRITICAL_ASSETS = ['/', '/styles/main.css', '/scripts/app.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(CRITICAL_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate event (clean old caches)
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(names => {
return Promise.all(
names
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});Fetch Interception Strategies
// Strategy 1: Cache First (static assets)
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image' ||
event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
});
})
);
}
});
// Strategy 2: Network First (API data)
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
}
});
// Strategy 3: Stale While Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
caches.open(CACHE_NAME).then(cache => cache.put(event.request, response.clone()));
return response;
});
return cached || fetchPromise;
})
);
});Offline Fallback
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
}
});Background Sync
// Main app: queue action for when online
navigator.serviceWorker.ready.then(reg => {
return reg.sync.register('sync-messages');
});
// Service Worker: execute when connection available
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(sendQueuedMessages());
}
});Decision Guide: Which Worker?
| Need | Solution |
|---|---|
| Heavy computation (sorting, parsing, crypto) | Web Worker |
| Share state across tabs | Shared Worker |
| Offline support, caching | Service Worker |
| Network interception, proxy | Service Worker |
| Push notifications | Service Worker |
| Background sync | Service Worker |
| Real-time data processing | Web Worker |
Interview Signal
Senior candidates demonstrate:
- Threading model â Main thread constraints, message passing, no shared memory
- Transfer vs copy â Transferable objects for performance
- Service Worker lifecycle â Install, activate, fetch, update flow
- Caching strategies â Cache-first, network-first, stale-while-revalidate and when each applies
- Practical judgment â When the overhead of workers is worth it vs. when it's not