The event loop, microtasks and output order
Almost every 'what does this log' question is one question wearing different clothes: the synchronous code runs to the end, then the entire microtask queue drains, then one task runs, then the microtask queue drains again. The trap is the word async. It does not mean 'on another thread' and it does not mean 'later, in parallel' - it means 'the callback goes in a queue and one thread will get to it when it has nothing else on the stack'. Say that sentence out loud and most of this page falls out of it.
The machine, in four parts
You are not being asked to recite this. You are being asked to reason with it in front of someone, so know the parts well enough to point at them while you talk.
- The call stack
- One stack, one thread. A function call pushes a frame, a return pops it. While anything is on the stack, nothing else in your JavaScript can run - no timer, no click handler, no promise callback. This is what 'single-threaded' actually means.
- The host APIs
- setTimeout, fetch, the DOM, file reads in Node. These are NOT JavaScript. They are C++ (or Rust) inside the browser or Node, and they can and do use other threads and the OS. You hand them a callback and they hand you back control immediately. This is the piece people forget, and it is where the concurrency actually lives.
- The task queue (macrotasks)
- When a host API finishes, it puts your callback in a queue: timer callbacks, DOM events, network callbacks, message events. One task is taken per turn of the loop.
- The microtask queue
- A separate, higher-priority queue for promise reactions (
.then,.catch,.finally, the resumption of anasyncfunction afterawait),queueMicrotask, and MutationObserver callbacks. It is not a task queue with a bigger number - it is checked at a different moment, and it is drained COMPLETELY, including anything added while draining. - The loop itself
- While the stack is empty: take one task, run it to completion, then drain every microtask, then (in a browser) let rendering happen if it is due, then repeat. That is the whole algorithm.
The one rule that answers most output questions
Sync first, all of it. Then every microtask, including microtasks queued by microtasks. Then ONE task. Then every microtask again. Repeat.
So a .then callback always beats a setTimeout(fn, 0) scheduled before it, and a promise queued inside a timer callback runs before the next timer callback - not after all the timers. If you only remember one thing from this page, remember that the microtask queue drains between every pair of tasks, not once at the end.
Node has the same loop with two extra queues
Node's loop runs in phases - timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks - and it drains microtasks between each phase and between each callback, so browser reasoning transfers.
The two additions worth knowing by name: process.nextTick, which has its own queue that drains BEFORE the promise microtask queue, and setImmediate, which fires in the check phase - meaning inside an I/O callback, setImmediate reliably beats setTimeout(fn, 0), while at the top level the two race and the order is genuinely not guaranteed.
Questions
Answer each one yourself before you open it.
01Is JavaScript single-threaded?
The JavaScript itself is: one call stack, one thing running at a time. The runtime around it is not - the browser and Node do timers, network and file I/O on other threads and hand the results back to that one thread as callbacks.
This is the distinction the whole topic hangs on. Your code never runs in parallel with your other code, which is why you never need a mutex around a counter. But the waiting does not happen on your thread, which is why a thousand in-flight fetches do not cost a thousand threads.
Follow-up So what actually runs the timer?
The host.
setTimeoutis a function the browser or Node exposes; it registers the callback with a timer implementation written in C++ and returns immediately. When the time is up, the host puts your callback in the task queue and the event loop picks it up when the stack is empty.02Explain the event loop in one sentence.
While the call stack is empty, it takes one callback from the task queue, runs it to completion, then drains the whole microtask queue, then repeats.
Two words are doing the work: 'empty' and 'completion'. Nothing is ever interrupted mid-function, so you never get torn state - and nothing queued can run while the stack is busy, which is why one slow function freezes the page.
03What is a microtask?
A callback on a higher-priority queue than tasks - promise reactions, the code after an
await,queueMicrotask, MutationObserver. The engine drains that queue completely after the current task, before it takes the next task.'Completely' is the graded word. If a microtask queues another microtask, that one runs in the same drain, before any timer, click handler or render gets a look in. That is what makes promise chains feel atomic relative to everything else on the page.
Follow-up Name a macrotask and a microtask.
Macrotask: a
setTimeoutcallback, a click handler, amessageevent. Microtask: a.thencallback, the line after anawait,queueMicrotask(fn).04Why does a
.thencallback run before asetTimeout(fn, 0)that was scheduled first?Because they are on different queues. The timer callback is a task, the
.thenis a microtask, and the entire microtask queue drains before the next task is taken - regardless of which was scheduled first.Scheduling order only decides position within a queue. Across queues, priority decides. This is the single most common output-order question, and the answer never changes: microtasks first.
05Why is
setTimeout(fn, 0)not actually zero?The delay is a minimum, not a promise. It means 'put this in the task queue no sooner than 0ms from now', and the callback still has to wait for the stack to empty and for every task ahead of it.
Three things stretch it. The stack has to clear first, so a 500ms synchronous loop delays it by 500ms. Browsers clamp nested timers to about 4ms after five levels of nesting. And background tabs throttle timers hard - often to once a second or worse.
Follow-up So what does
setTimeout(fn, 0)actually buy you?It yields. It lets the current task finish, lets the browser render, and lets pending events be processed before your callback runs. That is the whole point - you are moving work to the next turn, not making it fast.
06How many microtasks run between two timer callbacks?
All of them. The queue is drained until it is empty, including microtasks added during the drain.
Contrast with tasks: exactly one task runs per turn. Two
setTimeouts scheduled together will not both run before a render, but two hundred chained promises will all run before either timer.07Can a microtask starve the event loop?
Yes. A microtask that always queues another microtask never lets the drain finish, so no timer, no click, and no render ever happens again. The tab is frozen.
This is the real practical difference between
queueMicrotaskandsetTimeoutfor recursion. Recursing withsetTimeoutyields every iteration; recursing withqueueMicrotaskor.thendoes not, and the page dies without ever hitting a stack overflow.08When would you use
queueMicrotaskoversetTimeout(fn, 0)?When you want to defer until after the current synchronous block but BEFORE the browser does anything else - no render, no other events.
setTimeoutis the opposite: it deliberately lets everything else happen first.The typical use is consistency in a library: if a function is sometimes sync and sometimes async, you make it always async with
queueMicrotaskso callers get one ordering instead of two.Promise.resolve().then(fn)does the same thing;queueMicrotasksays what you mean and does not create a promise you then have to worry about swallowing errors.09Someone says 'making it async moves the work onto another thread'. Correct them.
It does not.
asyncandawaitare syntax over promises, and a promise is just an object with callbacks. If the work is aforloop over a million items, marking the functionasyncmoves nothing - the loop still runs on the one thread and still blocks the page for the same number of milliseconds.What actually happens off-thread is host work: the network request, the timer, the disk read.
awaitdoes not create that off-thread work, it just gives you a way to wait for it that does not block. Wrapping CPU work in a promise buys you nothing except a later start.Follow-up So how do you get CPU work off the main thread?
A Web Worker in the browser,
worker_threadsin Node. Real separate threads, no shared DOM, and you talk to them with messages (or a SharedArrayBuffer). Everything else is scheduling, not parallelism.10What does 'non-blocking I/O' actually mean here?
That waiting for the network or the disk does not occupy the thread. The request is handed to the OS, the thread goes back to running other JavaScript, and your callback is queued when the bytes arrive.
It says nothing about your code being fast. A single-threaded server handles ten thousand idle connections happily and falls over on one synchronous JSON parse of a 50MB body, because the first is waiting and the second is working.
11What kinds of code block the main thread?
Anything synchronous and long: a big loop,
JSON.parseorJSON.stringifyon a huge object, a heavy regex, sorting a hundred thousand rows, synchronous XHR,alert, and in Node the*Syncfamily likereadFileSync.Blocking is not a category of API, it is a duration. The test is 'does this occupy the stack for more than a frame', which in a browser is 16ms. Anything past ~50ms is a long task and the user feels it as jank.
Follow-up How do you fix a long task without a worker?
Chunk it and yield: process a slice, then
setTimeout(rest, 0)(orscheduler.yield()where it exists) so the browser gets a turn to render and handle input between slices. Slower overall, but the page stays alive.12Where does rendering fit in?
Between tasks, never during one. The browser can paint after a task finishes and after the microtask queue is drained - so a loop that mutates the DOM a hundred times inside one function paints once, at the end.
This is why you cannot show a progress bar from inside a synchronous loop: the style changes are applied to the DOM, but no frame is produced until the stack clears, and by then the loop is finished. It is also why 'the spinner does not spin' during heavy work.
13
requestAnimationFramevssetTimeout- when do you use which?requestAnimationFramefor anything visual. It runs just before the next paint, at the display's refresh rate, and it does not run at all in a background tab.setTimeoutfor logic on a schedule.A
setTimeout(fn, 16)drifts against the actual frame boundary, so you get double-paints and dropped frames.rAFis aligned to the frame by definition, and it automatically pauses when the tab is hidden, which is free battery.Follow-up Is
requestAnimationFramea macrotask or a microtask?Neither, strictly. Its callbacks run in the rendering steps of the loop, after the current task and its microtasks, just before style and layout. For interview purposes: it is task-like, and microtasks queued inside it still drain before the paint.
14What is wrong with
setInterval?It does not wait for the previous callback to finish, and the period is a minimum, so it drifts. If a tick takes longer than the interval, the callbacks queue up behind each other and can run back to back with no gap.
The interval also drifts cumulatively: each firing is scheduled off when the loop got to it, not off the start time, so 1000 ticks of a 10ms interval is never 10 seconds. For anything that must stay in step with the clock, compute the next delay from a fixed start time each tick.
Follow-up What do you use instead?
A recursive
setTimeoutthat reschedules itself at the END of the work - that guarantees a real gap between runs - and if timing accuracy matters, compute the next delay asstart + n * period - Date.now()so the error does not accumulate.15Does
awaitblock the thread?No. It returns control to the event loop. The rest of the function is registered as a continuation on the promise and resumes as a microtask when that promise settles.
It reads like a blocking call, which is the point of the syntax and also the trap. Inside the function, execution genuinely pauses; outside, the thread has moved on and is running other tasks. That is why an
awaitinside a loop serialises YOUR function without freezing the app.16Is the code after an
awaita microtask?Yes.
await pschedules the remainder of the function as a promise reaction onp, so it resumes on the microtask queue - one tick later ifpis an already-resolved native promise.It used to cost three ticks, because the spec wrapped the awaited value in an extra promise. Modern engines optimised that away for native promises, so an
awaitof a resolved promise resumes in one tick - which is why in the famous ordering puzzle,async1 endnow prints beforepromise2and did not in 2018.17What is
process.nextTickand how is it different from a promise?Node-only, and it is not a microtask - it has its own queue that is drained before the promise microtask queue, after every operation.
Because it jumps the promise queue, a recursive
process.nextTickstarves not just tasks but promises too. Node's own guidance is to reach forsetImmediateunless you specifically need to run before anything else, typically to let a caller attach handlers before you emit an error.Follow-up And
setImmediate?It queues a callback for the check phase, which comes right after the poll (I/O) phase. Inside an I/O callback it reliably runs before a
setTimeout(fn, 0); at the top level the two race, because whether the timers phase has passed the one-millisecond threshold yet depends on how long startup took.18Name Node's event loop phases.
Timers, pending callbacks, idle/prepare, poll, check, close callbacks - and the
nextTickplus microtask queues drain between every one of them.You only need the shape: timers fire expired
setTimeout/setInterval, poll is where I/O callbacks land and where the loop parks when it has nothing to do, check issetImmediate. The rest is trivia unless you are debugging a native addon.19Are DOM events tasks or microtasks?
Tasks. A click dispatched by the user is queued as a task and runs when the stack is empty. Dispatching one yourself with
element.dispatchEventis different - that runs the listeners synchronously, right there.That asymmetry catches people.
dispatchEventis a plain function call that invokes handlers on the current stack and returns after they finish; a real user click goes through the queue.20What is a 'tick' of the event loop?
One turn: take a task, run it, drain the microtasks. In Node the word is overloaded by
process.nextTick, which confusingly does NOT wait for the next tick - it runs at the end of the current operation.21How would you break a 2-second computation into pieces that keep the page responsive?
Slice the input, do a chunk, then yield with
setTimeout(next, 0)- orawaitascheduler.yield()/ message-channel yield if you want less than the timer clamp. Each yield lets the browser paint and handle input.You cannot yield with
queueMicrotaskorawait Promise.resolve(): microtasks drain before rendering, so the page still never paints. The yield has to be a task. If the work is truly heavy, a Worker is the right answer and the chunking is the fallback when the work touches the DOM.22Does
setTimeout(fn, 100)guarantee the callback runs 100ms later?It guarantees not sooner. Later is always possible, and in a busy or backgrounded page, much later.
23What is
requestIdleCallback?A way to run low-priority work when the browser has spare time at the end of a frame. The callback gets a deadline object telling it how many milliseconds it has, and it may never be called at all on a busy page unless you pass a timeout.
Right for analytics beacons, prefetching and cache cleanup. Wrong for anything the user is waiting on, and not supported in Safari, so it needs a fallback.
24Two promises resolve at the same time. Which
.thenruns first?The one whose reaction was queued first - the microtask queue is FIFO, so it is registration order, not resolution order.
This is why two independent chains interleave: A's first
.then, B's first.then, then A's second, then B's second. Each.thenonly queues the next link after its own callback returns.25Why does returning a promise from a
.thencost extra ticks?Because adopting a thenable takes two extra microtasks: one to call
.thenon the returned promise, one to run the resolve callback that follows. So a value returned from.thensettles the chain one tick later; a promise returned settles it three ticks later.You will rarely need this at work and it is a favourite of ordering puzzles. The honest interview answer is 'returning a promise adds two ticks, because the outer promise has to subscribe to the inner one' - that shows you know why rather than that you counted.
26Is an
asyncfunction's body asynchronous from the first line?No. It runs synchronously up to the first
await. Everything before that firstawaithappens on the caller's stack, before the caller's next line.This matters for validation and for side effects: throwing before the first
awaitstill produces a rejected promise, not a synchronous throw, but the code itself already ran. It is also why calling an async function and ignoring the result still does work immediately.27You have a click handler that is not firing while a function runs. Why?
The click is sitting in the task queue behind your function. The event was captured and queued the moment it happened, but nothing can be taken off the queue while the stack is busy.
Worth saying explicitly: the event is not lost. It fires the instant the stack clears, which is why a frozen page often processes five queued clicks at once when it recovers.
28Does the browser event loop reasoning transfer to Node?
Mostly, yes. Sync then microtasks then a task, and microtasks drain between callbacks in both. The differences are Node's phases, the
nextTickqueue that jumps ahead of promises,setImmediate, and the absence of a rendering step.29What happens to an unhandled promise rejection with respect to the loop?
The rejection is tracked, and if no handler is attached by the time the microtask queue finishes draining, the host fires
unhandledrejection- in the browser it logs an error, in Node 15 and later it throws and kills the process.The 'by the end of the drain' part is the interesting bit: attaching a
.catchin the same synchronous block is fine, attaching it in asetTimeoutis too late and you get the warning even though the handler eventually arrives.30When would you deliberately NOT use the event loop, and reach for a Worker?
When the work is CPU-bound and longer than a frame: parsing a big file, image processing, crypto, running a diff over a large document. Anything where yielding just spreads the same jank over more frames.
The cost is a message boundary - data is structured-cloned or transferred, and the Worker cannot touch the DOM. If the work is short or DOM-bound, the boundary costs more than it saves.
31Why do people say the event loop is 'a queue' and why is that not quite right?
There are several queues with different priorities and, in the browser, a rendering step wedged between turns. 'A queue' gets you the FIFO intuition and then fails on the first microtask question.
32What is the practical rule you would give a junior for choosing a scheduling primitive?
Visual work:
requestAnimationFrame. Yield to let the page breathe:setTimeout(fn, 0). Run right after this function but before anything else:queueMicrotask. Real CPU work: a Worker. NeversetIntervalfor anything whose work can outlast the period.
Predict the output
The output is hidden. Predict it before you open it.
The one they always open with
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');Show the output
1
4
3
2Both console.logs are synchronous, so 1 and 4 print first, in order. Then the stack empties and the microtask queue drains: 3. Only then is one task taken: 2. Scheduling the timer first changes nothing, because it is on the lower-priority queue.
A promise queued inside a timer
setTimeout(() => {
console.log('timeout');
Promise.resolve().then(() => console.log('inner promise'));
}, 0);
setTimeout(() => console.log('second timeout'), 0);
Promise.resolve().then(() => console.log('outer promise'));
console.log('sync');Show the output
sync
outer promise
timeout
inner promise
second timeoutThe point is where inner promise lands. Microtasks drain after EVERY task, not once at the end, so the promise queued inside the first timer runs before the second timer callback. If the drain only happened at the end you would get both timeouts first, and that is the wrong model.
The famous async/await ordering puzzle
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
console.log('script end');Show the output
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeoutAn async function body runs synchronously until its first await, which is why async1 start and async2 print before anything is queued. The Promise executor is synchronous too, hence promise1. Then microtasks: async1 end was queued before promise2 was, and with the modern one-tick await it resumes first. The timer is last, as always. Older articles show promise2 before async1 end - that was the pre-2018 three-tick await, and it is now wrong.
Awaiting a plain value still costs a tick
async function f() {
console.log('f1');
await 42;
console.log('f2');
}
f();
Promise.resolve().then(() => console.log('p'));
console.log('sync');Show the output
f1
sync
f2
pawait on a non-promise does not resume immediately - the value is wrapped and the continuation is queued as a microtask, exactly like awaiting a promise. f2 beats p because f's continuation was queued first, and the microtask queue is FIFO.
Microtasks queued during the drain
console.log('start');
queueMicrotask(() => {
console.log('micro 1');
queueMicrotask(() => console.log('micro 1.1'));
});
queueMicrotask(() => console.log('micro 2'));
setTimeout(() => console.log('macro'), 0);
console.log('end');Show the output
start
end
micro 1
micro 2
micro 1.1
macromicro 1.1 is queued while the queue is being drained, so it runs in the SAME drain - appended to the end, after micro 2, but still before any task. This is what 'drained completely' means and it is why a self-requeueing microtask can freeze the page.
A long loop and a pending timer
setTimeout(() => console.log('timer'), 0);
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
console.log('loop done');Show the output
loop done
timerThe timer expired within a millisecond and its callback has been sitting in the task queue for roughly a second. It cannot run, because the stack is not empty. setTimeout(fn, 0) means 'no sooner than now', never 'now'.
Two expired timers after a block
setTimeout(() => console.log('100ms timer'), 100);
setTimeout(() => console.log('0ms timer'), 0);
const stop = Date.now() + 200;
while (Date.now() < stop) {}
console.log('blocked for 200ms');Show the output
blocked for 200ms
0ms timer
100ms timerBy the time the stack clears, both timers are overdue. They still run in expiry order, not in the order they became ready, so the 0ms one goes first. Two overdue timers do not merge and neither is skipped.
Microtask starvation
let n = 0;
setTimeout(() => console.log('timeout'), 0);
function again() {
console.log('micro', ++n);
if (n < 3) queueMicrotask(again);
}
queueMicrotask(again);
console.log('sync');Show the output
sync
micro 1
micro 2
micro 3
timeoutThree self-requeueing microtasks all run before the timer. Remove the n < 3 guard and the timer never runs at all - the tab hangs with no stack overflow and no error, because each microtask returns cleanly before queueing the next.
Two independent promise chains
Promise.resolve()
.then(() => console.log('A1'))
.then(() => console.log('A2'));
Promise.resolve()
.then(() => console.log('B1'))
.then(() => console.log('B2'));Show the output
A1
B1
A2
B2They interleave. A chain only queues its next link after the current callback returns, so at drain time the queue holds [A1, B1]; running A1 appends A2 behind B1. Chains do not run to completion before other microtasks get a turn.
Returning a promise from `.then`
Promise.resolve()
.then(() => Promise.resolve('X'))
.then((v) => console.log(v));
Promise.resolve()
.then(() => console.log(1))
.then(() => console.log(2))
.then(() => console.log(3))
.then(() => console.log(4));Show the output
1
2
3
X
4Returning a promise makes the outer promise adopt it, and adoption costs two extra microtask ticks - one to call .then on the inner promise, one for the resolve callback. That is why X lands three ticks in, between 3 and 4, instead of one tick in next to 1. Returning a plain value would have printed X first.
Node: nextTick jumps the promise queue
process.nextTick(() => console.log('tick 1'));
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('tick 2'));
Promise.resolve().then(() => console.log('promise 2'));
console.log('sync');Show the output
sync
tick 1
tick 2
promise 1
promise 2process.nextTick is not a microtask - it has its own queue, and Node drains that queue entirely before touching the promise microtask queue. Interleaving the calls changes nothing: both ticks come out before both promises.
Node: setImmediate vs setTimeout, inside I/O
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});Show the output
immediate
timeoutInside an I/O callback the loop is in the poll phase, and check (where setImmediate lives) comes immediately after it, while timers is a whole lap away. This ordering IS guaranteed. At the top level of a script the same two lines have no guaranteed order at all - it depends on whether the process took a full millisecond to start.
rAF against a timer
requestAnimationFrame(() => console.log('raf'));
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');Show the output
sync
micro
timeout
rafThe first two lines are guaranteed: sync, then microtasks. The last two are near-certain rather than specified - the timer is due in about a millisecond while the next animation frame is up to about 16ms away, so on a normally-running page the timer wins. If a frame happened to be due immediately, raf could come first. Say the guaranteed part with confidence and name the rest as scheduling reality.
Everything at once
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
setTimeout(() => console.log('6'), 0);
});
console.log('7');Show the output
1
4
7
5
2
3
6Sync: 1, then the executor's 4, then 7 - the Promise constructor's function runs immediately, which is the half of this puzzle people miss. Drain: 5, which schedules a second timer behind the first. Task one: 2, then its microtask 3 before the loop moves on. Task two: 6.
Write it from memory
Complete, runnable code. Practice typing it from memory before your interview.
Yield to the browser between chunks
Process a large array without freezing the page. Ask for this whenever someone says 'the UI locks up while we render 50,000 rows'.
function processInChunks(items, work, chunkSize = 500) {
return new Promise((resolve) => {
let i = 0;
function runChunk() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) work(items[i], i);
if (i < items.length) {
setTimeout(runChunk, 0);
} else {
resolve();
}
}
runChunk();
});
}
// processInChunks(rows, (row) => render(row)).then(() => console.log('done'));- The yield MUST be a task.
queueMicrotaskorawait Promise.resolve()drains before rendering, so the page still never paints. - Total time goes up - you are paying ~4ms of timer clamp per chunk. Tune chunkSize so each chunk is under ~10ms, not so each chunk is large.
- If the work does not touch the DOM, a Worker is the better answer and this is the fallback.
An interval that does not drift and does not overlap
The standard follow-up to 'what is wrong with setInterval'. Reschedules from a fixed start time, and only after the work finishes.
function everyTick(fn, period) {
const start = Date.now();
let n = 0;
let timer = null;
let stopped = false;
async function run() {
n++;
await fn(n);
if (stopped) return;
const drift = Date.now() - (start + n * period);
timer = setTimeout(run, Math.max(0, period - drift));
}
timer = setTimeout(run, period);
return function stop() {
stopped = true;
clearTimeout(timer);
};
}
// const stop = everyTick(async () => { await poll(); }, 1000);
// stop();await fn(n)is what stops two runs overlapping - a slow tick delays the next one instead of stacking behind it.- The drift correction keeps the Nth tick near
start + n * periodinstead of accumulating the lateness of every previous tick. - Returning
stopmatters: an interval with no way to cancel is a leak in every component that mounts it.
rAF-throttled handler
For scroll, resize and mousemove. Runs at most once per frame, which is the most often anything visual can matter.
function rafThrottle(fn) {
let queued = false;
let lastArgs = null;
function wrapped(...args) {
lastArgs = args;
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
fn(...lastArgs);
});
}
wrapped.cancel = () => {
queued = false;
lastArgs = null;
};
return wrapped;
}
// window.addEventListener('scroll', rafThrottle(() => {
// header.style.opacity = String(Math.max(0, 1 - window.scrollY / 200));
// }));- Keeping
lastArgsmeans the frame uses the newest values, not the ones from when the frame was requested. - Better than
throttle(fn, 16)for visual work: it is aligned to the actual paint and it pauses in background tabs. - Say why you would not use it for a network call - a request per frame is 60 requests a second.