Measure before you optimise. Learn profiling tools, Core Web Vitals, and the techniques that actually make pages faster.

Bishal Biswas
Self Learner
Measure first
The most important rule in performance work: do not guess. Developers are famously bad at predicting what is slow. Measure, change one thing, measure again.
console.time('operation');
const result = [...Array(100000)].map((_, i) => i * 2);
console.timeEnd('operation');
For anything more serious, use performance.now(), which is more precise:
const start = performance.now();
doSomething();
console.log(`Took ${(performance.now() - start).toFixed(2)}ms`);
The Performance panel
DevTools gives you the real picture. Record while you use the page, then look for:
- Long tasks, meaning anything over 50ms, which block interaction.
- Layout thrashing, shown as repeated purple layout blocks.
- The flame chart, where wide bars are your expensive functions.
Core Web Vitals
Google measures three things that map to what users actually feel.
- LCP (Largest Contentful Paint): how long until the main content appears. Aim under 2.5s.
- INP (Interaction to Next Paint): how quickly the page responds to a click or tap. Aim under 200ms.
- CLS (Cumulative Layout Shift): how much things jump around. Aim under 0.1.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP:', entry.startTime.toFixed(0), 'ms');
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
Do less work on the main thread
Debounce and throttle
function debounce(fn, delay) {
let id;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => { waiting = false; }, limit);
};
}
const onSearch = debounce((term) => console.log('Searching', term), 300);
const onScroll = throttle(() => console.log('Scrolled'), 100);
Debounce waits for a pause, which suits search input. Throttle limits the rate, which suits scroll and resize.
Break up long tasks
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
doWork(items[i]);
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
Yielding every hundred items lets the browser handle clicks and repaints.
Do not fight the layout engine
Reading a layout property after writing one forces the browser to recalculate immediately.
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const width = box.offsetWidth;
box.style.width = width + 10 + 'px';
});
That is layout thrashing: read, write, read, write, each read forcing a recalculation.
Separate the phases:
const boxes = document.querySelectorAll('.box');
const widths = [...boxes].map(box => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});
Read everything, then write everything. Much faster.
Batch DOM insertions
const list = document.querySelector('#list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.append(li);
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.append(li);
}
list.append(fragment);
A document fragment lives outside the page, so building it costs nothing. One insertion instead of a thousand.
Animate the cheap properties
element.style.left = x + 'px';
element.style.transform = `translateX(${x}px)`;
transform and opacity can be handled by the GPU without recalculating layout. Changing left, width or top forces layout on every frame.
Load less JavaScript
The fastest code is code you never send.
button.addEventListener('click', async () => {
const { Chart } = await import('./chart.js');
new Chart();
});
Other wins:
- Tree shaking, which requires ES modules and named imports.
- Import only what you need, so
import { debounce } from 'lodash-es'rather than the whole library. deferortype="module", so scripts never block parsing.- Modern build targets, so you stop shipping polyfills for browsers nobody uses.
Cache expensive results
function memoise(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const slowSquare = memoise((n) => {
console.log('Calculating');
return n * n;
});
console.log(slowSquare(5), slowSquare(5));
Give real caches a size limit, or you have just built a memory leak.
Algorithms beat micro optimisation
This matters far more than any trick above.
function findUsersSlow(users, ids) {
return ids.map(id => users.find(u => u.id === id));
}
function findUsersFast(users, ids) {
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id));
}
The first is O(n squared). With 10,000 users and 10,000 ids it does 100 million comparisons. The second builds a lookup once and is effectively instant. No amount of loop tuning closes that gap.
Images and fonts are usually the real problem
JavaScript often is not the bottleneck.
// <img src="photo.jpg" loading="lazy" width="800" height="600" alt="">
// <link rel="preload" as="font" href="font.woff2" type="font/woff2" crossorigin>
loading="lazy"skips offscreen images.- Always set
widthandheightto prevent layout shift. - Use modern formats like WebP or AVIF.
- Preload fonts and use
font-display: swap.
A sensible order
- Measure with the Performance panel and Lighthouse.
- Fix the biggest thing first, which is usually images or bundle size.
- Look at algorithms in any hot path.
- Then reduce main thread work.
- Measure again to confirm it helped.
Quick recap
- Always measure before and after, never guess.
- LCP, INP and CLS are the metrics that reflect real experience.
- Debounce for pauses, throttle for rate limits.
- Batch DOM reads and writes separately to avoid layout thrashing.
- Animate
transformandopacity, notleftandwidth. - Split code and import only what you use.
- A better algorithm beats every micro optimisation.
// Measure first. Never optimise on a hunch
const start = performance.now();
const doubled = [...Array(100000)].map((_, i) => i * 2);
console.log('Took', (performance.now() - start).toFixed(2), 'ms');
// Debounce waits for a pause, throttle limits the rate
function debounce(fn, delay) {
let id;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => { waiting = false; }, limit);
};
}
const onSearch = debounce(term => console.log('Searching', term), 300);
const onScroll = throttle(() => console.log('Scrolled'), 100);
// SLOW: layout thrashing, every read forces a recalculation
function thrash(boxes) {
boxes.forEach(box => {
box.style.width = box.offsetWidth + 10 + 'px'; // read, write, read...
});
}
// FAST: read everything, then write everything
function batched(boxes) {
const widths = [...boxes].map(box => box.offsetWidth); // all reads
boxes.forEach((box, i) => { box.style.width = widths[i] + 10 + 'px'; });
}
// SLOW: 1000 separate insertions into the live page
// FAST: build off screen, insert once
const list = document.querySelector('#list');
if (list) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = 'Item ' + i;
fragment.append(li);
}
list.append(fragment);
}
// The biggest win is almost always the algorithm, not the loop
function findUsersSlow(users, ids) { // O(n squared)
return ids.map(id => users.find(u => u.id === id));
}
function findUsersFast(users, ids) { // build a lookup once
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id));
}
// Cache expensive results, but give the cache a limit in real code
function memoise(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
}
const square = memoise(n => { console.log('Calculating'); return n * n; });
console.log(square(5), square(5)); // calculates once
// Load code only when it is needed
document.querySelector('#chartBtn')?.addEventListener('click', async () => {
const { Chart } = await import('./chart.js');
new Chart();
});
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready