Skip to main content

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:

  1. Pick one macrotask from the macrotask queue (if available)

    • Execute it to completion (until the call stack is empty)
  2. 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
  3. Render (if needed)

    • The browser may update the screen
    • Run requestAnimationFrame callbacks
    • Calculate styles, layout, and paint
  4. 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

SourceDescription
setTimeout(fn, delay)Scheduled callback after delay
setInterval(fn, delay)Repeated scheduled callback
User eventsclick, keydown, mousemove, etc.
I/O operationsNetwork responses, file reads (Node.js)
MessageChannelCross-context messaging
setImmediate(fn)Node.js only, runs after I/O
Initial script executionThe <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

SourceDescription
Promise.then(), .catch(), .finally()Promise resolution/rejection handlers
queueMicrotask(fn)Explicit microtask scheduling
MutationObserver callbacksDOM change notifications
async/awaitCode 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.

warning

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: 1
  • setTimeout(cb_2, 0) schedules cb_2 as a macrotask
  • Promise.resolve().then(cb_4) schedules cb_4 as a microtask
  • setTimeout(cb_6, 0) schedules cb_6 as a macrotask
  • Promise.resolve().then(cb_7) schedules cb_7 as a microtask
  • console.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
}
}
}
tip

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:

  1. Synchronous code runs: logs 1 and 7
  2. Microtask queue is drained: queueMicrotask (4), Promise.then (5), MutationObserver (6)
  3. Rendering step (if it is time to render): requestAnimationFrame (3)
  4. Next macrotask: setTimeout (2)
note

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

PriorityTypeExamplesFrequency per loop iteration
HighestSynchronous codeCurrent script, function callsRuns to completion
HighMicrotasksPromise.then, queueMicrotask, MutationObserver, await continuationALL drained
MediumAnimation callbacksrequestAnimationFrameALL run (if rendering)
StandardMacrotaskssetTimeout, setInterval, events, MessageChannelONE 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, await continuations) 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.
  • requestAnimationFrame callbacks 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 setTimeout or scheduler.postTask to yield to the browser. Do not use queueMicrotask or Promise.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.