Skip to main content

Performance Optimization APIs

Browsers provide a variety of modern APIs for performance optimization. Using IntersectionObserver, MutationObserver, requestAnimationFrame, Web Worker, and ResizeObserver allows you to build smooth, responsive web applications.


IntersectionObserver

Asynchronously detects whether an element intersects with the viewport (or a specific ancestor element). Provides significantly better performance compared to scroll events.

Implementing Lazy Loading

// Image lazy loading
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;

const img = entry.target;
const src = img.dataset.src;

if (src) {
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
}

observer.unobserve(img); // stop observing after load
});
}, {
// rootMargin: intersection detection margin (pre-load 200px before entering viewport)
rootMargin: '200px 0px',
threshold: 0 // trigger when even 1 pixel is visible
});

// Start observing all lazy-load images
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});

// HTML
// <img data-src="image.jpg" src="placeholder.jpg" alt="Image">

Implementing Infinite Scroll

// Infinite scroll
class InfiniteScroll {
#observer;
#sentinel;
#isLoading = false;
#page = 1;

constructor(container, fetchMore) {
// sentinel: a detection element placed at the end of the list
this.#sentinel = document.createElement('div');
this.#sentinel.className = 'scroll-sentinel';
container.appendChild(this.#sentinel);

this.#observer = new IntersectionObserver(async (entries) => {
const entry = entries[0];
if (!entry.isIntersecting || this.#isLoading) return;

this.#isLoading = true;
this.#sentinel.classList.add('loading');

try {
const hasMore = await fetchMore(this.#page++);

if (!hasMore) {
this.#observer.disconnect();
this.#sentinel.remove();
}
} finally {
this.#isLoading = false;
this.#sentinel.classList.remove('loading');
}
}, {
rootMargin: '300px', // pre-load 300px before reaching the sentinel
threshold: 0
});

this.#observer.observe(this.#sentinel);
}

reset() {
this.#page = 1;
this.#isLoading = false;
}
}

// Usage
const container = document.querySelector('#post-list');

const infiniteScroll = new InfiniteScroll(container, async (page) => {
const response = await fetch(`/api/posts?page=${page}&limit=10`);
const { posts, hasMore } = await response.json();

posts.forEach(post => {
const el = createPostElement(post);
container.insertBefore(el, container.lastElementChild);
});

return hasMore;
});

Scroll Animations

// Animate elements when they enter the viewport
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-in');
// Stop observing after the animation runs once
animationObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.1, // trigger when more than 10% is visible
rootMargin: '-50px 0px' // trigger after entering 50px further
});

document.querySelectorAll('.animate-on-scroll').forEach(el => {
animationObserver.observe(el);
});

// Track progress with multiple thresholds
const progressObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
entry.target.style.opacity = ratio;
entry.target.style.transform = `translateY(${(1 - ratio) * 30}px)`;
});
}, {
threshold: Array.from({ length: 101 }, (_, i) => i / 100) // 101 values from 0 to 1
});

MutationObserver

Asynchronously detects changes to the DOM. Can detect attribute changes, child node additions/removals, and text changes.

// Basic usage
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
console.log('Type:', mutation.type);
// 'childList': child node change
// 'attributes': attribute change
// 'characterData': text content change

if (mutation.type === 'childList') {
console.log('Added nodes:', mutation.addedNodes);
console.log('Removed nodes:', mutation.removedNodes);
}

if (mutation.type === 'attributes') {
console.log('Changed attribute:', mutation.attributeName);
console.log('Previous value:', mutation.oldValue);
}
});
});

// Start observing
const target = document.querySelector('#dynamic-content');
observer.observe(target, {
childList: true, // detect child node additions/removals
attributes: true, // detect attribute changes
subtree: true, // include all descendant nodes
attributeOldValue: true, // record previous attribute value
characterData: true, // detect text changes
characterDataOldValue: true
});

// Stop observing
observer.disconnect();

Real-World: Initializing Dynamically Added Elements

// Handle elements added by third-party code or dynamic loading
function initializeNewElements(elements) {
elements.forEach(el => {
if (el.classList?.contains('tooltip')) {
new Tooltip(el);
}
if (el.classList?.contains('code-block')) {
highlightCode(el);
}
});
}

const observer = new MutationObserver((mutations) => {
const addedElements = [];

mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
addedElements.push(node);
// Include descendant elements
addedElements.push(...node.querySelectorAll('*'));
}
});
});

if (addedElements.length > 0) {
initializeNewElements(addedElements);
}
});

observer.observe(document.body, { childList: true, subtree: true });

// Detect theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.attributeName === 'class') {
const isDark = document.documentElement.classList.contains('dark');
updateTheme(isDark ? 'dark' : 'light');
}
});
});

themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'] // only detect changes to the class attribute
});

requestAnimationFrame

Runs a callback before the browser's next repaint. Provides timing optimized for animations.

60fps Animations

// Basic animation loop
function animate(timestamp) {
// timestamp: DOMHighResTimeStamp (in ms, very precise)
updateAnimation(timestamp);
requestAnimationFrame(animate); // schedule the next frame
}

const animationId = requestAnimationFrame(animate);

// Cancel
cancelAnimationFrame(animationId);

// Real-world: smooth scroll animation
function smoothScrollTo(targetY, duration = 500) {
const startY = window.scrollY;
const distance = targetY - startY;
const startTime = performance.now();

function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);

// ease-in-out easing function
const eased = progress < 0.5
? 2 * progress * progress
: -1 + (4 - 2 * progress) * progress;

window.scrollTo(0, startY + distance * eased);

if (progress < 1) {
requestAnimationFrame(step);
}
}

requestAnimationFrame(step);
}

// Real-world: counter animation
function animateCounter(element, from, to, duration = 1000) {
const startTime = performance.now();

function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);

// ease-out
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(from + (to - from) * eased);
element.textContent = current.toLocaleString();

if (progress < 1) {
requestAnimationFrame(update);
}
}

requestAnimationFrame(update);
}

// Real-world: game loop
class GameLoop {
#isRunning = false;
#animationId;
#lastTime = 0;
#fps = 60;
#fpsInterval = 1000 / 60;

start(updateFn) {
this.#isRunning = true;

const loop = (timestamp) => {
if (!this.#isRunning) return;

const elapsed = timestamp - this.#lastTime;

if (elapsed >= this.#fpsInterval) {
const delta = elapsed / 1000; // delta time in seconds
this.#lastTime = timestamp - (elapsed % this.#fpsInterval);
updateFn(delta);
}

this.#animationId = requestAnimationFrame(loop);
};

this.#animationId = requestAnimationFrame(loop);
}

stop() {
this.#isRunning = false;
cancelAnimationFrame(this.#animationId);
}
}

Advantages Over setInterval

// Bad approach: setInterval (wastes CPU, runs even when tab is inactive)
setInterval(() => {
updateGame(); // keeps running even when screen is not actually updating!
}, 1000 / 60);

// Good approach: requestAnimationFrame
// - Runs at the browser's optimal rendering timing
// - Automatically pauses when the tab is inactive (saves battery)
// - Syncs with the display refresh rate (144fps on 144Hz displays)
function gameLoop(timestamp) {
updateGame(timestamp);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);

Web Worker

Web Workers run JavaScript in a separate thread from the main thread. CPU-intensive tasks can be processed without blocking the main thread.

// worker.js (separate file)
// DOM access is not available inside a Web Worker
// But fetch, setTimeout, IndexedDB, etc. can be used

self.addEventListener('message', (e) => {
const { type, data } = e.data;

switch (type) {
case 'SORT': {
const sorted = [...data].sort((a, b) => a - b);
self.postMessage({ type: 'SORT_DONE', result: sorted });
break;
}

case 'FIBONACCI': {
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
const result = fib(data.n);
self.postMessage({ type: 'FIB_DONE', result });
break;
}

case 'PROCESS_IMAGE': {
// Image pixel processing
const { pixels, width, height } = data;
const result = applyGrayscaleFilter(pixels, width, height);
// Transfer using Transferable Objects (no copy)
self.postMessage({ type: 'IMAGE_DONE', result }, [result.buffer]);
break;
}
}
});

function applyGrayscaleFilter(pixels, width, height) {
const output = new Uint8ClampedArray(pixels.length);
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
output[i] = output[i+1] = output[i+2] = avg;
output[i+3] = pixels[i+3];
}
return output;
}
// main.js - main thread
const worker = new Worker('./worker.js');

// Receive messages
worker.addEventListener('message', (e) => {
const { type, result } = e.data;
switch (type) {
case 'SORT_DONE':
displaySortedResults(result);
break;
case 'FIB_DONE':
displayFibonacci(result);
break;
}
});

// Error handling
worker.addEventListener('error', (e) => {
console.error('Worker error:', e.message, e.filename, e.lineno);
});

// Send messages
worker.postMessage({ type: 'SORT', data: largeArray });
worker.postMessage({ type: 'FIBONACCI', data: { n: 40 } });

// Terminate the worker
worker.terminate();

// Promise wrapper for easier usage
class WorkerClient {
#worker;
#pending = new Map();
#idCounter = 0;

constructor(scriptUrl) {
this.#worker = new Worker(scriptUrl);
this.#worker.addEventListener('message', ({ data }) => {
const { id, result, error } = data;
const pending = this.#pending.get(id);
if (!pending) return;

this.#pending.delete(id);
if (error) pending.reject(new Error(error));
else pending.resolve(result);
});
}

request(type, data) {
return new Promise((resolve, reject) => {
const id = ++this.#idCounter;
this.#pending.set(id, { resolve, reject });
this.#worker.postMessage({ id, type, data });
});
}

terminate() {
this.#worker.terminate();
}
}

// Usage
const workerClient = new WorkerClient('./worker.js');
const sorted = await workerClient.request('SORT', hugeArray);

Inline Worker (Without a Separate File)

// Create an inline worker using a Blob URL
function createInlineWorker(fn) {
const code = `
self.onmessage = function(e) {
const result = (${fn.toString()})(e.data);
self.postMessage(result);
};
`;
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url); // free memory
return worker;
}

// Usage
const sortWorker = createInlineWorker((data) => {
return [...data].sort((a, b) => a - b);
});

sortWorker.onmessage = (e) => console.log('Sort result:', e.data);
sortWorker.postMessage([5, 3, 1, 4, 2]);

ResizeObserver

Detects changes in an element's size. Provides more precise detection than the window.resize event.

// Basic usage
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`Element resized: ${width}x${height}`);

// borderBoxSize: size including border
const borderBoxSize = entry.borderBoxSize[0];
console.log(`Including border: ${borderBoxSize.inlineSize}x${borderBoxSize.blockSize}`);

// contentBoxSize: size excluding padding
const contentBoxSize = entry.contentBoxSize[0];
});
});

resizeObserver.observe(document.querySelector('.responsive-chart'));

// Stop observing
resizeObserver.unobserve(element);
resizeObserver.disconnect(); // stop all

// Real-world: responsive chart
class ResponsiveChart {
#observer;
#chart;
#container;

constructor(container, chartLibrary) {
this.#container = container;

this.#observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.#onResize(width, height);
});

this.#observer.observe(container);
this.#initChart(container.clientWidth, container.clientHeight, chartLibrary);
}

#initChart(width, height, lib) {
this.#chart = lib.createChart(this.#container, { width, height });
}

#onResize(width, height) {
this.#chart?.resize(width, height);
}

destroy() {
this.#observer.disconnect();
this.#chart?.destroy();
}
}

// Real-world: updating CSS variables
const containerObserver = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
// Pass container width as a CSS variable
document.documentElement.style.setProperty('--container-width', `${width}px`);

// Update breakpoint classes
const el = entry.target;
el.classList.toggle('sm', width < 640);
el.classList.toggle('md', width >= 640 && width < 1024);
el.classList.toggle('lg', width >= 1024);
});

Performance Measurement API

// Precise measurement with the Performance API
const start = performance.now();
// perform work
const end = performance.now();
console.log(`Execution time: ${(end - start).toFixed(2)}ms`);

// Measure with markers
performance.mark('task-start');
heavyOperation();
performance.mark('task-end');
performance.measure('task-time', 'task-start', 'task-end');

const measure = performance.getEntriesByName('task-time')[0];
console.log(`Measurement result: ${measure.duration.toFixed(2)}ms`);

// Real-time monitoring with PerformanceObserver
const perfObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 50) { // tasks longer than 50ms
console.warn(`Long task detected: ${entry.name} - ${entry.duration.toFixed(2)}ms`);
}
});
});

perfObserver.observe({ entryTypes: ['measure', 'longtask'] });

Expert Tips

1. Common Utility for the Observer Pattern

// Manage multiple Observers collectively
class ObserverManager {
#observers = new Set();

add(observer) {
this.#observers.add(observer);
return observer;
}

disconnectAll() {
this.#observers.forEach(o => o.disconnect());
this.#observers.clear();
}
}

const observerManager = new ObserverManager();
observerManager.add(new IntersectionObserver(handler1));
observerManager.add(new MutationObserver(handler2));
observerManager.add(new ResizeObserver(handler3));

// Clean up when leaving the page
window.addEventListener('beforeunload', () => observerManager.disconnectAll());

2. Separating DOM Reads and Writes with requestAnimationFrame

// Prevent layout thrashing
function batchDOMUpdates(readFn, writeFn) {
requestAnimationFrame(() => {
const data = readFn(); // read (reflow)
requestAnimationFrame(() => {
writeFn(data); // write (in the next frame)
});
});
}

3. Sharing Data with Web Worker + SharedArrayBuffer

// SharedArrayBuffer: main thread and worker share memory
// Atomics: synchronized atomic operations
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);

// Pass to worker (shared, not copied)
worker.postMessage({ buffer: sharedBuffer });

// In the worker:
// const sharedArray = new Int32Array(e.data.buffer);
// Atomics.add(sharedArray, 0, 1); // atomic increment