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