Course: JavaScript Advanced
Lesson 12: Performance Basics
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Explain what layout thrashing is and how to avoid it by batching DOM reads and writes
- Use
requestAnimationFrameto synchronize visual updates with the browser's render cycle - Identify the difference between reflow-triggering and paint-only changes
Frontend Development Context
JavaScript performance problems often have nothing to do with algorithm complexity — they come from the wrong interaction pattern with the browser's rendering engine. A loop that alternates between reading and writing DOM geometry can trigger dozens of synchronous layout calculations in milliseconds, making what should be an instant animation feel like it's running through mud. Understanding how the browser's render pipeline works gives you the ability to write animations and DOM updates that stay consistently at 60 frames per second.
Frontend performance at the advanced level means knowing the rules of when the browser has to recalculate layout (reflow), when it only has to repaint pixels (repaint), and when it can skip both and just composite layers. These distinctions let you choose between element.style.left and element.style.transform, know when to use will-change, and understand why requestAnimationFrame exists.
Explanation
The browser renders a page through a series of steps: JavaScript runs, then style is recalculated, then layout (also called reflow) computes element positions and sizes, then paint draws pixels, then composite combines painted layers. Not every change triggers every step — this is the performance opportunity.
Reflow (layout) is expensive. It recalculates the position and size of every element in the document that might be affected by a change. Changing width, height, top, left, margin, padding, font-size, or reading geometry properties like offsetWidth, getBoundingClientRect(), or scrollTop can trigger reflow. When you mix reads and writes in a loop, you can force the browser to perform synchronous layout recalculations on every iteration — this is layout thrashing.
The fix for layout thrashing is to batch DOM reads and writes separately. Read all your geometry values first, store them in variables, then perform all your DOM mutations. This way, the browser only needs to recalculate layout once per batch instead of once per iteration.
Repaint is cheaper than reflow. Changing properties like color, background-color, or border-color triggers a repaint but not a reflow. The browser redraws the affected pixels without recalculating any element positions.
Composite-only changes are cheapest of all. Properties like transform and opacity can be handled entirely by the compositor thread without touching the layout or paint phases at all. This is why CSS animations using transform: translate() are smooth even when other JavaScript is running — the compositor runs independently on the GPU. Prefer transform over top/left for any animations.
requestAnimationFrame (rAF) schedules a callback to run before the browser's next render frame. This synchronizes your visual updates with the browser's 60 fps render cycle, preventing tearing and ensuring updates are always timed optimally. It also automatically pauses when the tab is hidden, saving CPU and battery.
Code Example
This example demonstrates layout thrashing, the batch-read-then-write fix, and using requestAnimationFrame for a smooth animation loop.
const boxes = document.querySelectorAll(".box");
// --- Layout thrashing (BAD) ---
function positionBoxesBad() {
boxes.forEach(box => {
// READ: forces synchronous layout (reflow)
const width = box.offsetWidth;
// WRITE: invalidates layout
box.style.width = (width + 10) + "px";
// Next iteration: READ again — forces reflow AGAIN because layout is stale
});
// Result: N reflows for N boxes
}
// --- Batch read then write (GOOD) ---
function positionBoxesGood() {
// Phase 1: read all values first
const widths = Array.from(boxes).map(box => box.offsetWidth);
// Phase 2: write all values after all reads complete
boxes.forEach((box, i) => {
box.style.width = (widths[i] + 10) + "px";
});
// Result: 1 reflow (triggered by first .offsetWidth), then 1 paint
}
// --- requestAnimationFrame animation loop ---
function animateProgress(element, targetWidth, duration) {
const start = performance.now();
const initialWidth = element.offsetWidth; // read once before loop
function frame(timestamp) {
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
// Use transform for composite-only update (no reflow, no repaint)
const currentWidth = initialWidth + (targetWidth - initialWidth) * progress;
element.style.width = currentWidth + "px";
if (progress < 1) {
requestAnimationFrame(frame); // schedule next frame
} else {
console.log("Animation complete");
}
}
requestAnimationFrame(frame); // start loop
}
// Prefer transform over left/top for moving elements
function slideIn(element) {
element.style.transform = "translateX(0)"; // compositor only — no reflow
element.style.transition = "transform 0.3s ease";
}
function hideElement(element) {
element.style.opacity = "0"; // compositor only — no reflow, often no repaint
element.style.transition = "opacity 0.2s";
}
Code Explanation
positionBoxesBad illustrates layout thrashing. Each iteration reads offsetWidth, which forces the browser to compute current layout because the previous iteration's write invalidated it. Then it writes, invalidating layout again for the next read. With 20 boxes this causes 20 synchronous reflows in one synchronous JavaScript call — the browser can't batch them.
positionBoxesGood fixes this by separating the read and write phases. All offsetWidth reads happen first in a single map call. Since no writes have happened yet, the browser only needs to compute layout once for the first read — subsequent reads in the same phase can reuse the cached layout. All writes happen after, in a separate loop. The browser can now batch the write phase into a single layout/paint pass.
animateProgress uses requestAnimationFrame to animate element width. The frame callback receives a high-precision timestamp from the browser. Progress is calculated as a value from 0 to 1 based on elapsed time, making the animation frame-rate-independent. If the browser runs at 30fps instead of 60fps, the animation still takes the same duration milliseconds — it just renders fewer frames. requestAnimationFrame is called recursively until progress reaches 1.
The slideIn and hideElement functions demonstrate the practical preference for transform and opacity over left/top and display. These properties can be animated by the browser's compositor without triggering reflow or repaint in the main thread.
Pattern Highlights
✅ Positive Pattern: Batch all DOM reads before any DOM writes in the same task — if you need to read geometry after a write, schedule the read in the next
requestAnimationFramecallback rather than forcing a synchronous reflow.
⚠️ Neutral Note:
will-change: transformhints to the browser that an element will be composited separately, which can improve animation smoothness but also increases GPU memory usage. Apply it only to elements that are actively animating, and remove it after the animation ends.
❌ Negative Pattern: Do not use
setIntervalorsetTimeoutfor animations — their timing is not synchronized with the browser's render cycle, causing tearing and inconsistent frame rates. Always userequestAnimationFramefor visual updates.
Common Mistakes
Mistake 1: Reading layout properties inside a write loop
The classic layout thrashing mistake: alternating between writes and reads forces repeated synchronous reflows.
const items = document.querySelectorAll(".item");
items.forEach(item => {
const height = item.clientHeight; // READ — forces reflow (layout stale from last write)
item.style.height = (height * 1.5) + "px"; // WRITE — invalidates layout
});
Separate the reads into one pass and the writes into a second pass. Read all clientHeight values first, then apply all height changes.
Mistake 2: Animating left/top instead of transform
Moving an element by changing left or top triggers reflow on every frame because the browser must recalculate how the change affects surrounding elements.
// Causes reflow every frame — expensive
function animate() {
element.style.left = (parseFloat(element.style.left) + 2) + "px";
requestAnimationFrame(animate);
}
Use transform: translateX() instead. The compositor handles transforms without touching layout, keeping the main thread free for other work.
Mistake 3: Running an animation loop without a stop condition
A recursive requestAnimationFrame loop without a termination condition runs forever, consuming CPU even after the animation should be done.
function loop() {
updateAnimation(); // runs forever
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
Track the animation state with a progress value or a running flag, and only call requestAnimationFrame again if the animation is not complete. Store the returned frame ID from requestAnimationFrame so you can cancel it with cancelAnimationFrame when needed.
Quick Recap
- Reflow (layout) is the browser recalculating element positions and sizes — it is triggered by geometry reads (
offsetWidth,getBoundingClientRect) and writes (width,height,margin) and is expensive when interleaved. - Layout thrashing occurs when reads and writes are alternated in a loop, forcing repeated synchronous reflows — fix it by batching all reads before all writes.
requestAnimationFrameschedules visual updates to run before the browser's next paint, synchronizing animations with the render cycle and making them frame-rate-independent.- Animate with
transformandopacityrather thantop/left/width— they are handled by the compositor and do not trigger layout recalculation.