Rendering & the event loop
Learn where browser frames fit between tasks, why requestAnimationFrame runs before paint, and how to batch page updates smoothly.
- 01Place a frameExplain where a rendering opportunity sits after a task and its microtasks without promising a fixed schedule.
- 02Use rAF correctlyChoose requestAnimationFrame for visual updates and explain its timestamp and before-paint timing.
- 03Avoid extra layout workBatch DOM reads before writes, group visual changes in one frame, and plan for hidden tabs.
Frames between tasks
A browser does not paint after every JavaScript statement. It runs a task, drains the microtasks created by that task, and then may get a chance to update the screen. That chance is called a rendering opportunity. It is where a browser can prepare and paint a new frame.
A rendering opportunity is a browser-selected moment when a visible document can update its rendering. It is not a promise after every task, and it is affected by the display, page load, and whether the document is visible.
This lesson continues the HTML event loop processing model. That lesson established tasks and microtasks. Here the missing piece is the frame: where visual work fits, why requestAnimationFrame is special, and why grouping DOM work helps a page feel responsive.
The useful question is not “when does JavaScript paint?” JavaScript does not paint. The browser owns rendering and chooses its opportunities. Your code can prepare state at the right time, then let the browser calculate style, layout, and paint.
Rendering opportunities and frame rate
A display that refreshes at 60 hertz can show about 60 pictures each second. Dividing one second by 60 gives roughly 16.7 milliseconds per picture. That is a helpful mental budget, not a deadline that the web platform guarantees. A faster display, a busy page, or a hidden tab can change what opportunities the browser provides.
const picturesPerSecond = 60;console.log((1000 / picturesPerSecond).toFixed(1));Line 1 names a common display rate. Line 2 divides 1,000 milliseconds by 60 and prints 16.7. This only describes a useful example. Do not write code that assumes each rAF timestamp advances by exactly 16.7.
A flipbook shows one picture, then another. Before the next page flips, you can fix the drawing on the next page. The important part is that you prepare the picture before it is shown, rather than expecting every pencil movement to be visible at once.
- In real life: One flipbook page
- In JavaScript: One possible rendered frame
- In real life: A pause before the next flip
- In JavaScript: A rendering opportunity
- In real life: Fix the drawing before flipping
- In JavaScript: Update state in requestAnimationFrame
- In real life: Pages can flip less often
- In JavaScript: The browser can skip or throttle opportunities
Where the analogy stops: A browser has many rendering steps and may choose when to run them; a flipbook does not model all of that scheduling.
When a task runs too long, the browser cannot run its rendering steps until that task returns. This is why expensive JavaScript makes a page miss visual updates. The browser may then show the next complete frame later instead of showing a half-finished one.
requestAnimationFrame timing
requestAnimationFrame, often shortened to rAF, asks the browser to call a function before a future paint. It is the normal place to prepare a visual change such as moving a card, applying a progress value, or updating an animation state. It is not a timer and it does not make a frame happen by itself.
requestAnimationFrame((time) => console.log("before the next paint", time > 0));Line 1 queues a callback. When the browser reaches an appropriate rendering opportunity, it calls the function with a high-resolution timestamp. The callback prints before the next paint true in a browser because the timestamp is a positive time value.
requestAnimationFrame((time) => console.log("before the next paint", time > 0));setTimeout(() => console.log("timer task"), 0);Promise.resolve().then(() => console.log("promise microtask"));console.log("task finished");Line 1 asks for a frame callback. Line 2 queues a timer task. Line 3 queues a promise microtask. Line 4 prints task finished immediately. The stable ordering fact is that promise microtask runs before timer task, because microtasks drain after the current task before another task is selected. The rAF callback waits for a rendering opportunity and runs before that opportunity's paint.
Do not claim a fixed order between an arbitrary timer and a particular future rAF callback. Browser scheduling can vary. Instead, use the guarantees that matter: a promise reaction does not interrupt the task that scheduled it, and rAF is part of the rendering update rather than a regular timer task.
The lesson test uses Chrome when it can start. It checks two stable facts: two rAF callbacks queued in one task receive the same timestamp, and a promise microtask queued by that task runs before its rAF callback. It intentionally does not assert frame duration.
What happens in one frame
The HTML standard's “update the rendering” algorithm has many details. The plain version is still useful: the browser handles resize and scroll-related work, updates media queries and animations, runs rAF callbacks, then calculates style and layout before it paints. Observers fit into that rendering update too.
| Step | Plain-language order | Why code might notice |
|---|---|---|
| Resize events | First listed author-visible work | Respond to a viewport or element resize event. |
| Scroll events | After resize | Read a current scroll position or update a small UI state. |
| Media queries and animations | Before rAF | The browser updates query and animation state for this opportunity. |
| Fullscreen | Before rAF | Fullscreen changes are processed before frame callbacks. |
| requestAnimationFrame | Before style/layout/paint | Prepare visual changes for the frame that may be painted. |
| Style and layout | After rAF | Compute which rules apply and where boxes belong. |
| ResizeObserver | During style/layout loop | Receive size changes; another layout pass may be needed. |
| IntersectionObserver | After layout work | Receive visibility intersection updates before paint. |
| Paint | Last | Draw the current visual result when the browser updates the display. |
This order explains why rAF is a preparation point. If an rAF callback changes a class or a transform, the style and layout phase that follows can include that change. A callback that reads layout after changing it can still ask the browser for a current measurement, so placement helps but does not remove the need to batch reads and writes.
Step through an instrumented teaching model of one task, its microtasks, and one rendering opportunity.
script
function runTurn(state) { const events = [`task: ${state.task}`]; events.push(...state.microtasks.map((name) => `microtask: ${name}`)); state.clock += 16.7; events.push("frame: requestAnimationFrame"); events.push("frame: style, layout, paint"); state.frames.push(state.clock); return events;} console.log(runTurn(model).join(" | "));Style, layout, and paint
Style means deciding which CSS rules apply. Layout means calculating box sizes and positions. Paint means drawing the visible pixels for the current result. They are different jobs, even though developers often say “render” for all of them.
const cart = document.querySelector("[data-cart]");requestAnimationFrame(() => { cart?.classList.add("is-open"); console.log("cart state prepared");});Line 1 finds a cart element. Line 2 queues the before-paint callback. Line 3 changes one class, and line 4 prints cart state prepared when the callback runs. The browser then decides which style rules apply, computes layout if needed, and eventually paints the new result.
A property such as transform can often be updated without changing the layout of surrounding boxes, while a property such as width may require layout. That is a useful performance clue, but it is not a promise that every transform is free or every width change is bad. Measure a real interaction before rewriting it.
const state = { clock: 0, task: "change cart", microtasks: ["save cart"], frames: [] }; function runTurn(model) { const events = [`task: ${model.task}`]; events.push(...model.microtasks.map((name) => `microtask: ${name}`)); model.clock += 16.7; if (model.clock >= 16.7) { events.push("frame: requestAnimationFrame"); events.push("frame: style, layout, paint"); model.frames.push(model.clock); } return events;} console.log(runTurn(state).join(" | "));The model first records a task, then its microtask, then a frame phase. It deliberately compresses the browser's algorithm. Its job is to make the boundary visible: JavaScript code finishes a task before the browser can reach the next frame work.
Resize, scroll, and observers
A resize event and a scroll event are not random callbacks sprinkled between your JavaScript lines. In the rendering update, the browser runs resize steps first and scroll steps next. Media queries are evaluated after that, which lets a page react to a changed viewport consistently.
const card = document.querySelector("[data-card]");const observer = new ResizeObserver((entries) => { console.log("card width", entries[0].contentRect.width);});if (card) observer.observe(card);Line 1 gets the element. Line 2 creates a ResizeObserver. Line 3 logs the measured width when the browser delivers an entry. Line 4 starts observing. ResizeObserver belongs in the rendering update after style and layout work, which is why it can report an element's current size without you using a timer to check repeatedly.
IntersectionObserver comes later in the same broad update, after layout work. It tells code that an element intersects a root or viewport. Both observers should do small work. If an observer changes sizes again and again, the browser may need more layout work and can report a resize-observer loop problem.
const card = document.querySelector("[data-product]");const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) console.log("product is visible");});if (card) observer.observe(card);Line 1 selects a product card. Line 2 creates an observer. Line 3 prints product is visible when the entry intersects. Line 4 closes the callback, and line 5 starts observation. This is a cleaner signal than checking visibility on every timer tick.
Batch work into one frame
DOM reads ask questions such as “what is this element's width?” DOM writes change classes, styles, text, or nodes. When code repeatedly writes and then reads, each read may need the browser to make layout current early. This pattern is often called layout thrashing.
const priceWidth = price.getBoundingClientRect().width;const cartWidth = cart.getBoundingClientRect().width; requestAnimationFrame(() => { cart.style.width = `${cartWidth + priceWidth}px`; cart.classList.add("has-total");});Lines 1 and 2 read both measurements before a write. Line 4 schedules the visual change. Lines 5 and 6 group the writes in one callback. This code does not guarantee a number of layouts, but it gives the browser one clear batch instead of interleaving read and write demands.
A common practical pattern is: handle input in its task, store the latest value, and use one pending rAF callback to apply visual changes. If ten pointer events arrive before the next frame, the callback can use the newest value once instead of doing ten separate visual updates.
Replay an instrumented layout-cost model. Browsers have more detail, but the useful rule is real: read first, then group writes.
script
function countLayouts(plan) { let dirty = false; let layouts = 0; for (const action of plan) { if (action === "write") dirty = true; if (action === "read" && dirty) { layouts += 1; dirty = false; } } return layouts + Number(dirty);} console.log(countLayouts(actions));const reads = ["measure cart", "measure price"];const writes = ["move cart", "show total"]; function countLayouts(plan) { let dirty = false; let layouts = 0; for (const action of plan) { if (action === "write") dirty = true; if (action === "read" && dirty) { layouts += 1; dirty = false; } } return layouts + Number(dirty);} console.log(countLayouts(["read", "read", "write", "write"]));read -> read -> write -> writeReads ask for current geometry. Writes change styles or DOM state.
1Batching avoids the middle read after a write.
taskThe work happens in an ordinary task; the browser still chooses when its next rendering opportunity arrives.
The work happens in an ordinary task; the browser still chooses when its next rendering opportunity arrives. This batched plan needs 1 layout pass in the teaching model; the other order needs 2.
Rendering in background tabs
A hidden page is not a small visible page. Browsers save battery and CPU by reducing work for background documents. In normal browser use, rAF callbacks stop or pause when a document is hidden, and timer callbacks are throttled. Do not use either one as a wall-clock guarantee.
const pageVisible = false;const animationCanRun = pageVisible;const timerPolicy = pageVisible ? "normal scheduling" : "throttled scheduling"; console.log(animationCanRun ? "rAF can run" : "rAF is paused");console.log(timerPolicy);Line 1 models a hidden page. Line 2 says animation cannot run in that model. Line 3 chooses a throttled timer policy. Lines 5 and 6 print rAF is paused and throttled scheduling. The real browser has more policies, but the application rule is simple: resume from current state when the page becomes visible instead of expecting background animation steps to have happened.
Use visibilitychange to pause optional work or refresh a display when it becomes visible again. For elapsed time, compare a real clock value when work resumes. Do not count rAF calls and assume they represent every missed second.
Practical page updates
Suppose a shopping cart follows a pointer while a reader drags it open. The pointer event is a task. Store the latest pointer position in that handler. If no rAF callback is pending, queue one. The rAF callback reads the stored position once and writes one transform for the next frame.
let latestX = 0;let pending = false; window.addEventListener("pointermove", (event) => { latestX = event.clientX; if (pending) return; pending = true; requestAnimationFrame(() => { document.documentElement.style.setProperty("--cart-x", `${latestX}px`); pending = false; console.log("updated cart", latestX); });});Line 1 stores the latest input. Line 4 updates it for every pointer task. Lines 5 through 7 ensure only one rAF callback is waiting. Line 9 makes the visual write, line 10 unlocks a future callback, and line 11 logs the value that was used. This avoids treating every input event as a separate paint request.
Keep the rAF callback short. Prepare values in ordinary tasks when possible, avoid expensive DOM queries inside a tight animation loop, and stop visual work when a component unmounts. For more page-level tools, continue with rendering performance, animation, observers, and main thread.
- A
Promise.thencallback - A
queueMicrotaskcallback - A
setTimeoutcallback - A user click handler
- A
requestAnimationFramecallback - A
ResizeObservernotification
Sort each item by the event-loop phase it belongs to. Read the explanation after each placement.
Common misconceptions
- “rAF is a precise 60 fps timer.” It is a before-paint callback. The rate and even whether it runs depend on the browser.
- “A timer with zero delay runs immediately.” It creates a later eligible task after the current task and microtasks finish.
- “Every DOM write paints now.” Browsers batch work and decide when to update rendering.
- “rAF removes all layout cost.” It coordinates visual work; interleaved reads and writes can still cause extra layout work.
- “Background tabs keep animations alive.” Hidden documents commonly pause rAF and throttle timers.
| Idea | What it really says | What it does not promise |
|---|---|---|
| rAF always runs every 16.7 ms | A visible 60 Hz page often has opportunities around that interval. | A guarantee of a fixed duration or a callback every display refresh. |
| rAF is a timer | It is a visual callback tied to rendering opportunities. | A general background scheduling API. |
| One write equals one paint | Writes can be batched until the browser renders. | A command that instantly updates pixels. |
| Hidden tabs animate normally | Browsers commonly pause rAF and throttle timers. | A safe place to depend on animation progress. |
| Tool | Where it runs | Good use |
|---|---|---|
| Promise callback | Microtask after current task | Finish small follow-up JavaScript before a later task or frame. |
| setTimeout | A later task when eligible | Do non-visual deferred work; it does not mean next paint. |
| requestAnimationFrame | Before style, layout, and paint at a rendering opportunity | Prepare a visual update for an upcoming frame. |
| ResizeObserver | During the rendering update after layout | React to an element's measured size without polling. |
Practice exercises
Read the code. What word starts the comma-separated output?
const order = [];
Promise.resolve().then(() => order.push("promise"));
setTimeout(() => order.push("timer"), 0);
order.push("task");
setTimeout(() => console.log(order.join(",")), 10);The output starts with task. The current task pushes it before the promise microtask and timer callback can run.
Which queued callback runs before the zero-delay timer?
const order = [];
Promise.resolve().then(() => order.push("promise"));
setTimeout(() => order.push("timer"), 0);
order.push("task");
setTimeout(() => console.log(order.join(",")), 10);The promise microtask runs before the timer. The final output is task,promise,timer.
A product card should move smoothly on screen. Which API should prepare its visual update before paint?
requestAnimationFrame(() => {
cart.classList.add("is-open");
});Use requestAnimationFrame to prepare a visual change before the browser's style, layout, and paint work.
Type the safer order for two measurements and two visual changes.
const plan = ["read", "read", "write", "write"];
console.log(plan.join(" then "));Use reads then writes. Read all geometry first, then group the DOM changes so a later read does not interrupt the batch.
What happens to rAF while the page is hidden in the lesson's model?
const visible = false;
console.log(visible ? "animate" : "pause animation");rAF pauses in a hidden tab. Resume from the current state on visibility change rather than expecting every animation step to occur.
Your cart follows a pointer. What should the rAF callback group into one update?
Group DOM writes in one rAF callback. The pointer handler can store the latest position, while the frame callback updates the transform once.
Check your understanding
Answer by keeping three layers separate: the current task, its microtasks, and a possible future rendering opportunity.
Question 1 of 6After a normal task finishes, what runs before a later timer task?
Choose an answer to see the explanation.
Question 2 of 6What does this print first?
Read the code, then predictconst order = []; Promise.resolve().then(() => order.push("promise")); setTimeout(() => order.push("timer"), 0); order.push("task"); setTimeout(() => console.log(order.join(",")), 10);Choose an answer to see the explanation.
Question 3 of 6Why is requestAnimationFrame special?
Choose an answer to see the explanation.
Question 4 of 6Two rAF callbacks are queued in the same task. What stable fact can you rely on?
Choose an answer to see the explanation.
Question 5 of 6Which order avoids an extra forced layout in the teaching model?
Read the code, then predictconst plan = ["read", "read", "write", "write"]; console.log(plan.join(" then "));Choose an answer to see the explanation.
Question 6 of 6What should animation code expect in a background tab?
Choose an answer to see the explanation.
Key takeaways
- A frame is browser work that can happen after a task and its microtasks, not after every JavaScript line.
- At 60 Hz, 16.7 ms is a useful rough budget, not a guaranteed rAF interval.
- rAF runs before style, layout, and paint at a rendering opportunity, so it is for visual preparation.
- Resize, scroll, media queries, animations, observers, and paint have defined places in the rendering update.
- Read layout values first, then group DOM writes into one rAF callback when visual work is involved.
- Background documents can pause rAF and throttle timers, so callback counts are not a clock.
Remember the one-liner.
Use requestAnimationFrame to prepare one visual update before the browser's next possible paint.
Coming next: Events, microtasks & ordering.