Observers & Workers
Observers let you react to changes in the DOM, element visibility, and element size without polling. Workers let you run JavaScript off the main thread so the UI stays responsive. Together, they form the backbone of performant, modern web applications.
IntersectionObserver
IntersectionObserver detects when elements enter or leave the viewport (or a specified container). It replaces expensive scroll event listeners with an efficient, browser-optimized API.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} is visible`);
}
});
});
observer.observe(document.getElementById('section-3'));
Lazy Loading Images
The most common use case. Load images only when they scroll into view.
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img); // Stop watching after load
}
});
});
document.querySelectorAll('img.lazy').forEach((img) => {
imageObserver.observe(img);
});
Infinite Scroll
Watch a sentinel element at the bottom of a list. When it becomes visible, load more content.
const sentinel = document.getElementById('scroll-sentinel');
const scrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems();
}
}, { rootMargin: '200px' }); // Trigger 200px before reaching the sentinel
scrollObserver.observe(sentinel);
Analytics Tracking
Track which sections users actually see.
const analyticsObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
trackImpression(entry.target.dataset.section);
analyticsObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 }); // 50% of element must be visible
Options
const observer = new IntersectionObserver(callback, {
root: null, // null = viewport, or a scrollable container element
rootMargin: '0px', // Margin around root (grow/shrink observation area)
threshold: 0.0, // 0.0 to 1.0, or array [0, 0.25, 0.5, 0.75, 1.0]
});
ResizeObserver
ResizeObserver fires when an element's size changes. This is useful for responsive components that need to adapt based on their own dimensions, not just the viewport.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const { width, height } = entry.contentRect;
console.log(`Element resized: ${width}x${height}`);
});
});
observer.observe(document.getElementById('resizable-panel'));
Real-World Use Case: Responsive Chart
const chartContainer = document.getElementById('chart');
const resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
redrawChart(width, height);
});
resizeObserver.observe(chartContainer);
Container Queries Alternative
Before CSS container queries existed, ResizeObserver was the only way to style components based on their own size.
const cardObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const card = entry.target;
if (entry.contentRect.width < 300) {
card.classList.add('compact');
} else {
card.classList.remove('compact');
}
});
});
MutationObserver
MutationObserver watches for changes to the DOM. It replaces the deprecated Mutation Events API with a more performant, batched approach.
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
console.log('Children changed:', mutation.addedNodes, mutation.removedNodes);
}
if (mutation.type === 'attributes') {
console.log(`Attribute ${mutation.attributeName} changed`);
}
});
});
observer.observe(document.getElementById('dynamic-list'), {
childList: true, // Watch for added/removed children
attributes: true, // Watch for attribute changes
subtree: true, // Watch all descendants too
});
Use Cases
- Third-party script monitoring: detect when an injected script modifies your DOM
- Accessibility tooling: watch for missing alt attributes on dynamically added images
- Framework internals: many frameworks use MutationObserver to detect DOM changes
Disconnecting
Always disconnect observers when you no longer need them.
// Stop observing
observer.disconnect();
// Or stop observing a specific element
observer.unobserve(targetElement);
Web Workers
Web Workers run JavaScript in a separate thread. The main thread stays free for UI work while the worker handles CPU-intensive tasks.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (event) => {
console.log('Result:', event.data);
};
// worker.js
self.onmessage = (event) => {
const result = heavyComputation(event.data);
self.postMessage(result);
};
function heavyComputation(data) {
// CPU-intensive work that would block the UI
return data.data.map((item) => complexTransform(item));
}
What Workers Cannot Do
- Access the DOM (no
document, nowindow) - Access
localStorageorsessionStorage - Manipulate the UI directly
What Workers Can Do
- Fetch API (network requests)
- IndexedDB (async storage)
setTimeout/setInterval- Import scripts with
importScripts() - Use
crypto,WebSocket, and other APIs
Transferable Objects
For large data, use Transferable objects to move (not copy) data between threads.
const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
worker.postMessage(buffer, [buffer]);
// buffer is now empty in main thread (transferred, not copied)
Service Workers
Service Workers sit between your application and the network. They enable offline support, background sync, push notifications, and intelligent caching.
// Register a service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((reg) => console.log('SW registered:', reg.scope))
.catch((err) => console.error('SW failed:', err));
}
Cache-First Strategy
// sw.js
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('v1').then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
Lifecycle
- Register: the page tells the browser about the service worker
- Install: download and cache assets (runs once per version)
- Activate: clean up old caches, take control
- Fetch: intercept network requests
Push Notifications
Service workers can receive push messages even when the page is closed.
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
})
);
});
Common Pitfalls
- Forgetting to unobserve/disconnect: observers keep running and consuming resources. Clean them up when elements are removed or components unmount.
- Heavy work in observer callbacks: observer callbacks run on the main thread. Keep them lightweight and defer heavy work.
- Assuming Web Workers share state: workers communicate only through messages. They have no access to variables in the main thread.
- Not handling the service worker update cycle: users can get stuck on old cached versions. Implement a proper update strategy.
- Using MutationObserver on the entire document with subtree: watching the whole DOM tree for every change is expensive. Scope it to the smallest element you need.
- Copying large data to workers: use Transferable objects or SharedArrayBuffer to avoid expensive copies.
Key Takeaways
- IntersectionObserver replaces scroll event listeners for visibility detection (lazy loading, infinite scroll, analytics)
- ResizeObserver lets components respond to their own size changes, not just viewport changes
- MutationObserver watches for DOM changes and is useful for detecting third-party modifications
- Web Workers run JavaScript off the main thread for CPU-intensive tasks but cannot touch the DOM
- Service Workers sit between app and network, enabling offline support, caching, and push notifications
- Always clean up observers and terminate workers when they are no longer needed