How the JavaScript Event Loop Works: Microtasks and Macrotasks Explained
JavaScript is single-threaded. It has one call stack, one memory heap, and it executes one piece of code at a time. Yet JavaScript applications handle user clicks, network requests, timers, animations, and DOM updates seemingly simultaneously. This is possible because of the event loop, the mechanism that coordinates the execution of synchronous code, asynchronous callbacks, rendering updates, and everything else that happens in a browser environment.
Understanding the event loop is not just theoretical knowledge. It directly affects how your code behaves, why certain bugs occur, why the UI freezes during heavy computation, and how to write code that remains responsive. This guide breaks down the event loop step by step, explains the critical difference between microtasks and macrotasks, shows the exact execution order the browser follows, and covers practical techniques for keeping your UI smooth and responsive.
The Event Loop Explained
The event loop is a continuously running process that checks whether there is work to do and executes it in a specific order. To understand it, you first need to understand the components it coordinates.
The Components
Call Stack: The call stack is where JavaScript executes functions. When you call a function, it is pushed onto the stack. When it returns, it is popped off. JavaScript processes one stack frame at a time, top to bottom. If the stack is not empty, JavaScript is busy and cannot do anything else.
Task Queues: When asynchronous operations complete (a timer fires, a network response arrives, the user clicks a button), their callbacks are not executed immediately. Instead, they are placed into queues. The event loop picks tasks from these queues and pushes them onto the call stack when the stack is empty.
Microtask Queue: A high-priority queue for microtasks (Promise callbacks, queueMicrotask, MutationObserver). This queue is drained completely before anything else happens.
Macrotask Queue (Task Queue): A standard-priority queue for macrotasks (setTimeout, setInterval, I/O callbacks, UI events). One macrotask is processed per event loop iteration.
Rendering Pipeline: The browser's mechanism for updating what the user sees on screen (style calculation, layout, paint). This runs between macrotasks when needed, typically targeting 60 frames per second.
The Loop, Step by Step
Here is what the event loop does on every iteration:
-
Pick one macrotask from the macrotask queue (if available)
- Execute it to completion (until the call stack is empty)
-
Process all microtasks in the microtask queue
- Execute each one to completion
- If a microtask adds new microtasks, process those too
- Continue until the microtask queue is completely empty
-
Render (if needed)
- The browser may update the screen
- Run
requestAnimationFramecallbacks - Calculate styles, layout, and paint
-
Repeat
- Go back to step 1 and continue the loop.
The critical insight is the asymmetry: one macrotask per iteration, but all microtasks are drained before moving on. This is what gives microtasks their higher priority.
Visualizing the Flow
Consider this code:
console.log('1: Script start');
setTimeout(() => {
console.log('2: setTimeout callback');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise callback');
});
console.log('4: Script end');
The execution unfolds like this:
Call Stack Microtask Queue Macrotask Queue
───────────────────── ───────────────── ─────────────────
log('1: Script start') (empty) (empty)
→ prints "1: Script start"
setTimeout(cb, 0) (empty) [setTimeout cb]
→ schedules callback in macrotask queue
Promise.resolve() [Promise cb] [setTimeout cb]
.then(cb)
→ schedules callback in microtask queue
log('4: Script end') [Promise cb] [setTimeout cb]
→ prints "4: Script end"
── Script (initial macrotask) complete ──
── Now drain ALL microtasks ──
Promise cb (empty) [setTimeout cb]
→ prints "3: Promise callback"
── Microtask queue empty ──
── Render if needed ──
── Pick next macrotask ──
setTimeout cb (empty) (empty)
→ prints "2: setTimeout callback"
Output:
1: Script start
4: Script end
3: Promise callback
2: setTimeout callback
The initial script itself is a macrotask. After it completes, the microtask queue is drained (Promise callback), and then the next macrotask (setTimeout callback) is picked up.
Macrotasks: setTimeout, setInterval, I/O, UI Rendering
Macrotasks (also simply called "tasks") are the standard units of work in the event loop. Each macrotask runs to completion before the event loop moves on.
What Creates Macrotasks
| Source | Description |
|---|---|
setTimeout(fn, delay) | Scheduled callback after delay |
setInterval(fn, delay) | Repeated scheduled callback |
| User events | click, keydown, mousemove, etc. |
| I/O operations | Network responses, file reads (Node.js) |
MessageChannel | Cross-context messaging |
setImmediate(fn) | Node.js only, runs after I/O |
| Initial script execution | The <script> tag itself is a macrotask |
setTimeout with Zero Delay
setTimeout(fn, 0) does not mean "execute immediately." It means "schedule this callback as a macrotask as soon as possible." Because it is a macrotask, it will not run until after the current script finishes and after all microtasks are drained:
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
console.log('Sync');
// Output:
// Sync
// Promise
// Timeout
The actual minimum delay for setTimeout is not 0ms. Browsers clamp it to approximately 4ms for nested timers (after 5 nesting levels). Background tabs may be throttled to 1000ms or more.
Each Event Handler Is a Separate Macrotask
When the user clicks a button, the browser queues a click event macrotask. If the user clicks three times quickly, three separate macrotasks are queued:
button.addEventListener('click', () => {
console.log('Click handler start');
Promise.resolve().then(() => {
console.log('Microtask inside click');
});
console.log('Click handler end');
});
One click produces:
Click handler start
Click handler end
Microtask inside click
Two quick clicks produce:
Click handler start ← First macrotask
Click handler end
Microtask inside click ← Microtasks drained
Click handler start ← Second macrotask
Click handler end
Microtask inside click ← Microtasks drained
Microtasks run between each macrotask, not after all macrotasks.
Multiple Timer Ordering
Macrotasks are processed one at a time. If multiple timers fire at the same time, they are queued and processed in order:
setTimeout(() => console.log('Timer 1'), 0);
setTimeout(() => console.log('Timer 2'), 0);
setTimeout(() => console.log('Timer 3'), 0);
// Output (each is a separate macrotask):
// Timer 1
// Timer 2
// Timer 3
Between each timer, the microtask queue is drained and the browser may render.
Microtasks: Promise Handlers, queueMicrotask, MutationObserver
Microtasks are higher-priority callbacks that run immediately after the current task completes, before the browser renders or processes the next macrotask. The microtask queue is drained completely every time the call stack becomes empty.
What Creates Microtasks
| Source | Description |
|---|---|
Promise.then(), .catch(), .finally() | Promise resolution/rejection handlers |
queueMicrotask(fn) | Explicit microtask scheduling |
MutationObserver callbacks | DOM change notifications |
async/await | Code after await (implicit .then()) |
Promise Handlers Are Always Microtasks
Even if a Promise is already resolved, its .then() handler does not execute synchronously. It is always scheduled as a microtask:
const resolved = Promise.resolve('done');
console.log('Before then');
resolved.then(value => {
console.log('Promise:', value);
});
console.log('After then');
// Output:
// Before then
// After then
// Promise: done
This is by design. It guarantees that Promise handlers always run asynchronously, making their timing predictable regardless of whether the Promise was already resolved or is resolved later.
queueMicrotask(): Explicit Microtask Scheduling
The queueMicrotask() function lets you schedule a microtask directly without creating a Promise:
console.log('Start');
queueMicrotask(() => {
console.log('Microtask 1');
});
queueMicrotask(() => {
console.log('Microtask 2');
});
console.log('End');
// Output:
// Start
// End
// Microtask 1
// Microtask 2
queueMicrotask is cleaner than Promise.resolve().then(fn) when you just need microtask timing without Promise semantics:
// ❌ Unnecessary Promise creation just for microtask timing
Promise.resolve().then(() => {
doSomething();
});
// ✅ Direct microtask scheduling
queueMicrotask(() => {
doSomething();
});
Microtasks Can Schedule More Microtasks
This is a critical behavior: if a microtask schedules another microtask, the new one is added to the queue and processed before the event loop moves on to rendering or the next macrotask. The queue is drained recursively:
console.log('Script start');
queueMicrotask(() => {
console.log('Microtask 1');
queueMicrotask(() => {
console.log('Microtask 2 (nested)');
queueMicrotask(() => {
console.log('Microtask 3 (deeply nested)');
});
});
});
setTimeout(() => {
console.log('Macrotask (setTimeout)');
}, 0);
console.log('Script end');
// Output:
// Script start
// Script end
// Microtask 1
// Microtask 2 (nested)
// Microtask 3 (deeply nested)
// Macrotask (setTimeout)
All three microtasks run before the setTimeout macrotask, even though microtasks 2 and 3 did not exist when the queue started draining.
Because microtasks are drained recursively, an infinite loop of microtasks will freeze the page permanently. The browser cannot render or process any macrotask until the microtask queue is empty:
// ❌ This freezes the browser: infinite microtask loop
function freeze() {
queueMicrotask(freeze);
}
freeze();
// The page is now completely unresponsive
This is different from an infinite setTimeout loop, which would still allow the browser to render between iterations. Treat microtask scheduling with care.
MutationObserver Callbacks Are Microtasks
MutationObserver callbacks are delivered as microtasks, which means they run before the browser renders the DOM changes:
const observer = new MutationObserver((mutations) => {
console.log('MutationObserver: DOM changed');
});
observer.observe(document.body, { childList: true });
console.log('Before DOM change');
document.body.appendChild(document.createElement('div'));
console.log('After DOM change');
// Output:
// Before DOM change
// After DOM change
// MutationObserver: DOM changed
async/await and Microtasks
Code after an await expression is essentially a .then() callback, so it runs as a microtask:
async function example() {
console.log('Async start'); // Synchronous
await Promise.resolve(); // Yields, continues as microtask
console.log('After await'); // Microtask
}
console.log('Script start');
example();
console.log('Script end');
// Output:
// Script start
// Async start
// Script end
// After await
The function runs synchronously until the first await, then the rest is scheduled as a microtask.
The Execution Order: Call Stack, Microtasks, Render, Macrotask
Let's trace through a complex example that involves all parts of the event loop:
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
setTimeout(() => {
console.log('6');
}, 0);
Promise.resolve().then(() => {
console.log('7');
});
console.log('8');
Let's trace through step by step:
Step 1: Execute the initial script (macrotask)
console.log('1')runs. Output:1setTimeout(cb_2, 0)schedules cb_2 as a macrotaskPromise.resolve().then(cb_4)schedules cb_4 as a microtasksetTimeout(cb_6, 0)schedules cb_6 as a macrotaskPromise.resolve().then(cb_7)schedules cb_7 as a microtaskconsole.log('8')runs. Output:8
State: Call stack empty. Microtask queue: [cb_4, cb_7]. Macrotask queue: [cb_2, cb_6].
Step 2: Drain all microtasks
- cb_4 runs:
console.log('4'). Output:4. Schedules cb_5 as macrotask. - cb_7 runs:
console.log('7'). Output:7.
State: Microtask queue empty. Macrotask queue: [cb_2, cb_6, cb_5].
Step 3: Render (if needed), then pick next macrotask
- cb_2 runs:
console.log('2'). Output:2. Schedules cb_3 as microtask.
Step 4: Drain all microtasks
- cb_3 runs:
console.log('3'). Output:3.
Step 5: Pick next macrotask
- cb_6 runs:
console.log('6'). Output:6.
Step 6: Pick next macrotask
- cb_5 runs:
console.log('5'). Output:5.
Final output:
1
8
4
7
2
3
6
5
The Key Rule
The pattern is always: finish the current task, drain all microtasks, optionally render, then pick the next macrotask. Microtasks always "cut in line" before the next macrotask.
A Diagram of One Event Loop Iteration
┌──────────────────────────────────────────────┐
│ EVENT LOOP ITERATION │
│ │
│ 1. ┌──────────────────────────────┐ │
│ │ Execute ONE Macrotask │ │
│ │ (or the initial script) │ │
│ └──────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ 2. ┌──────────────────────────────┐ │
│ │ Drain ALL Microtasks │◄──┐ │
│ │ (Promise.then, queueMicro- │ │ │
│ │ task, MutationObserver) │ │ │
│ └──────────────┬───────────────┘ │ │
│ │ │ │
│ │ New microtask │ │
│ │ added? ───────────┘ │
│ │ No │
│ ▼ │
│ 3. ┌──────────────────────────────┐ │
│ │ Render (if needed) │ │
│ │ • requestAnimationFrame │ │
│ │ • Style calculation │ │
│ │ • Layout │ │
│ │ • Paint │ │
│ └──────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ Back to Step 1 │
└──────────────────────────────────────────────┘
Implications for UI Responsiveness
Because JavaScript is single-threaded, the browser cannot update the screen while JavaScript is running. If your code takes 500ms to execute, the page is frozen for 500ms. No clicks are processed, no animations run, and no visual updates appear.
Long Tasks Block Everything
// ❌ This freezes the page for ~2 seconds
button.addEventListener('click', () => {
const start = Date.now();
// Simulate heavy computation
while (Date.now() - start < 2000) {
// Blocking loop
}
console.log('Done');
});
// The UI is completely frozen during those 2 seconds
During that 2-second loop, the browser cannot:
- Respond to clicks or keyboard input
- Run CSS animations or transitions
- Scroll the page
- Update the DOM visually
- Process any other event handlers
Microtask Overload Also Blocks
Remember that the microtask queue must be fully drained before the browser can render. A large burst of microtasks blocks rendering just like a long synchronous task:
// ❌ This blocks rendering: 100,000 microtasks before the browser can paint
for (let i = 0; i < 100000; i++) {
queueMicrotask(() => {
// Some work
processItem(i);
});
}
All 100,000 microtasks run before the browser gets a chance to render. If each takes even 0.01ms, that is a full second of freezing.
How to Detect Long Tasks
The browser provides the Long Tasks API to detect when tasks exceed 50ms:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration.toFixed(1)}ms`);
console.log('Task name:', entry.name);
}
});
observer.observe({ type: 'longtask', buffered: true });
Tasks longer than 50ms are considered "long" because they exceed one frame at 60fps (16.7ms) and cause visible jank.
Using setTimeout(fn, 0) to Yield to the Browser
The classic technique for keeping the UI responsive during heavy computation is to break the work into small chunks, using setTimeout to yield control back to the browser between chunks. Because setTimeout creates a macrotask, the browser gets a chance to render and process events between chunks.
Chunking Heavy Work
// ❌ Blocks the UI for the entire computation
function processAllItems(items) {
for (const item of items) {
heavyComputation(item);
}
}
// ✅ Processes in chunks, yielding to the browser between them
function processInChunks(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
heavyComputation(items[index]);
}
if (index < items.length) {
// Yield to the browser, then continue
setTimeout(processChunk, 0);
} else {
console.log('All items processed');
}
}
processChunk();
}
processInChunks(largeArray);
Between each chunk, the event loop completes an iteration: microtasks are drained, the browser can render, user events can be processed, and then the next chunk starts.
With Progress Updates
function processWithProgress(items, onProgress, onComplete) {
let index = 0;
const total = items.length;
const chunkSize = 50;
function processChunk() {
const chunkEnd = Math.min(index + chunkSize, total);
while (index < chunkEnd) {
heavyComputation(items[index]);
index++;
}
// Update progress bar
const progress = (index / total) * 100;
onProgress(progress);
if (index < total) {
setTimeout(processChunk, 0);
} else {
onComplete();
}
}
processChunk();
}
// Usage
processWithProgress(
largeArray,
(percent) => {
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent.toFixed(0)}%`;
},
() => {
console.log('Complete!');
}
);
Without setTimeout, the progress bar would jump from 0% to 100% instantly because the browser never gets a chance to render the intermediate states.
Why Not queueMicrotask for Yielding?
Using microtasks instead of setTimeout does not yield to the browser:
// ❌ Does NOT yield to the browser: microtasks block rendering
function processChunkMicro() {
// ... process some items ...
if (moreItems) {
queueMicrotask(processChunkMicro); // Still blocks rendering!
}
}
Because all microtasks are drained before rendering, this is equivalent to a single long synchronous task. Always use setTimeout (or requestAnimationFrame or scheduler.postTask) to yield to the browser.
The Modern Alternative: scheduler.postTask()
The scheduler.postTask() API (available in some modern browsers) provides more control over task priority:
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
heavyComputation(items[i]);
// Yield every 50 items
if (i % 50 === 49) {
await scheduler.postTask(() => {}, { priority: 'user-visible' });
}
}
}
A simpler cross-browser yield function:
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
heavyComputation(items[i]);
if (i % 50 === 49) {
await yieldToMain(); // Let the browser breathe
}
}
}
For truly heavy computation, consider using a Web Worker. Workers run on a separate thread and do not block the main thread at all. The event loop yielding technique is best for work that needs DOM access (which Workers cannot do).
requestAnimationFrame in the Event Loop
requestAnimationFrame (rAF) occupies a special position in the event loop. It is neither a macrotask nor a microtask. rAF callbacks run as part of the rendering step, after microtasks are drained but before the browser paints the screen.
Where rAF Fits in the Loop
1. Execute macrotask
2. Drain microtask queue
3. IF it's time to render (typically ~16.7ms intervals for 60fps):
a. Run requestAnimationFrame callbacks ← Here
b. Calculate styles
c. Calculate layout
d. Paint
4. Pick next macrotask
This positioning is ideal for visual updates because your changes are applied just before the browser paints.
rAF vs. setTimeout for Animation
// ❌ setTimeout: may fire between frames, causing jank or wasted work
function animateWithTimeout(element) {
let position = 0;
function step() {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position < 300) {
setTimeout(step, 16); // Roughly 60fps, but imprecise
}
}
step();
}
// ✅ requestAnimationFrame: synced with the display refresh rate
function animateWithRAF(element) {
let position = 0;
function step() {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position < 300) {
requestAnimationFrame(step); // Exactly one call per frame
}
}
requestAnimationFrame(step);
}
setTimeout with a 16ms delay is not synchronized with the display's refresh cycle. It might fire slightly early or late, causing frames to be skipped or double-painted. requestAnimationFrame is guaranteed to fire exactly once before each paint, at the display's native refresh rate.
rAF Callbacks and Microtasks
Microtasks scheduled inside a rAF callback run immediately after that callback, before the next rAF callback or the paint:
requestAnimationFrame(() => {
console.log('rAF 1');
Promise.resolve().then(() => {
console.log('Microtask inside rAF 1');
});
});
requestAnimationFrame(() => {
console.log('rAF 2');
});
// Output:
// rAF 1
// Microtask inside rAF 1
// rAF 2
Using rAF to Batch DOM Reads and Writes
A powerful pattern is using rAF to separate DOM reads from DOM writes, preventing layout thrashing:
// ❌ Layout thrashing: read → write → read → write
elements.forEach(el => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = (height * 2) + 'px'; // Write (invalidates layout)
// Next iteration: another read forces another layout recalculation
});
// ✅ Batch reads, then batch writes in rAF
const heights = elements.map(el => el.offsetHeight); // All reads first
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = (heights[i] * 2) + 'px'; // All writes together
});
});
Canceling rAF
requestAnimationFrame returns an ID that can be used to cancel the callback:
const id = requestAnimationFrame(callback);
cancelAnimationFrame(id); // Callback will not run
rAF Is Not Called in Background Tabs
When a tab is in the background, browsers pause requestAnimationFrame callbacks to save CPU and battery. This is different from setTimeout and setInterval, which continue running (though often throttled to once per second).
This makes rAF ideal for animations: they automatically pause when the user switches tabs and resume when they return.
Putting It All Together: A Complete Ordering Example
Here is a comprehensive example that involves every type of async scheduling:
console.log('1: Script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
requestAnimationFrame(() => {
console.log('3: requestAnimationFrame');
});
queueMicrotask(() => {
console.log('4: queueMicrotask');
});
Promise.resolve().then(() => {
console.log('5: Promise.then');
});
const observer = new MutationObserver(() => {
console.log('6: MutationObserver');
});
observer.observe(document.body, { attributes: true });
document.body.setAttribute('data-test', 'value');
console.log('7: Script end');
Expected output:
1: Script start
7: Script end
4: queueMicrotask
5: Promise.then
6: MutationObserver
3: requestAnimationFrame
2: setTimeout
The execution order is:
- Synchronous code runs: logs 1 and 7
- Microtask queue is drained:
queueMicrotask(4),Promise.then(5),MutationObserver(6) - Rendering step (if it is time to render):
requestAnimationFrame(3) - Next macrotask:
setTimeout(2)
The exact ordering of requestAnimationFrame relative to setTimeout can vary slightly between browsers and depends on whether the browser decides to render on this event loop iteration. In practice, rAF usually runs before the next setTimeout(fn, 0), but this is not guaranteed by the specification.
Event Loop Ordering Cheat Sheet
| Priority | Type | Examples | Frequency per loop iteration |
|---|---|---|---|
| Highest | Synchronous code | Current script, function calls | Runs to completion |
| High | Microtasks | Promise.then, queueMicrotask, MutationObserver, await continuation | ALL drained |
| Medium | Animation callbacks | requestAnimationFrame | ALL run (if rendering) |
| Standard | Macrotasks | setTimeout, setInterval, events, MessageChannel | ONE per iteration |
Common Pitfalls and Patterns
Pitfall: Assuming setTimeout(fn, 0) Runs Immediately
let value = 'initial';
setTimeout(() => {
console.log(value); // "modified", not "initial"
}, 0);
value = 'modified';
The timeout callback runs in a future macrotask, long after the current script modifies value.
Pitfall: Promise Resolution Order with async/await
async function a() {
console.log('a1');
await Promise.resolve();
console.log('a2');
}
async function b() {
console.log('b1');
await Promise.resolve();
console.log('b2');
}
a();
b();
console.log('sync');
// Output:
// a1
// b1
// sync
// a2
// b2
Both a2 and b2 are microtasks scheduled by the await. They run in the order they were scheduled, after the synchronous code completes.
Pattern: Ensuring DOM Updates Are Visible
Sometimes you need to ensure a DOM change is rendered before proceeding. For example, showing a loading indicator before heavy work:
// ❌ Loading indicator never appears: heavy work runs in the same task
showLoadingIndicator();
doHeavyWork(); // Browser never gets to render the indicator
hideLoadingIndicator();
// ✅ Loading indicator appears: yield to browser between show and work
showLoadingIndicator();
setTimeout(() => {
doHeavyWork();
hideLoadingIndicator();
}, 0);
// ✅ Even better: use requestAnimationFrame to ensure rendering
showLoadingIndicator();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Double rAF: first one schedules the paint, second one runs after it
doHeavyWork();
hideLoadingIndicator();
});
});
The double requestAnimationFrame pattern ensures the browser has actually painted the loading indicator before the heavy work begins. The first rAF schedules the callback before the next paint, and the second rAF runs after that paint has completed.
Pattern: Deferring Work Until After Render
function afterNextPaint(callback) {
requestAnimationFrame(() => {
// This runs before the paint
requestAnimationFrame(() => {
// This runs after the paint
callback();
});
});
}
// Usage: run analytics after the UI has updated
afterNextPaint(() => {
trackPageView();
sendAnalytics();
});
Summary
The JavaScript event loop is the mechanism that enables asynchronous programming in a single-threaded language:
- The call stack executes one function at a time. While code is on the stack, nothing else can run.
- Macrotasks (
setTimeout,setInterval, user events, I/O) are processed one per event loop iteration. The initial script is itself a macrotask. - Microtasks (Promise handlers,
queueMicrotask,MutationObserver,awaitcontinuations) are drained completely after each macrotask. New microtasks added during draining are also processed before moving on. An infinite microtask loop freezes the page permanently. requestAnimationFramecallbacks run during the rendering step, after microtasks but before the browser paints. They are ideal for visual updates and animations.- The execution order per iteration is: one macrotask then all microtasks then render (rAF, styles, layout, paint) then repeat.
- Long tasks block everything: rendering, user input, animations. Break heavy work into chunks using
setTimeout(fn, 0)to yield to the browser between chunks. - Use
setTimeoutorscheduler.postTaskto yield to the browser. Do not usequeueMicrotaskorPromise.resolve().then()for yielding, as microtasks do not allow rendering between them. - For heavy computation that does not need DOM access, use Web Workers to run on a separate thread entirely.