Index

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 an async function after await), 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.

  1. 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. setTimeout is 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.

  2. 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.

  3. 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 setTimeout callback, a click handler, a message event. Microtask: a .then callback, the line after an await, queueMicrotask(fn).

  4. 04Why does a .then callback run before a setTimeout(fn, 0) that was scheduled first?

    Because they are on different queues. The timer callback is a task, the .then is 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.

  5. 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.

  6. 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.

  7. 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 queueMicrotask and setTimeout for recursion. Recursing with setTimeout yields every iteration; recursing with queueMicrotask or .then does not, and the page dies without ever hitting a stack overflow.

  8. 08When would you use queueMicrotask over setTimeout(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. setTimeout is 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 queueMicrotask so callers get one ordering instead of two. Promise.resolve().then(fn) does the same thing; queueMicrotask says what you mean and does not create a promise you then have to worry about swallowing errors.

  9. 09Someone says 'making it async moves the work onto another thread'. Correct them.

    It does not. async and await are syntax over promises, and a promise is just an object with callbacks. If the work is a for loop over a million items, marking the function async moves 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. await does 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_threads in Node. Real separate threads, no shared DOM, and you talk to them with messages (or a SharedArrayBuffer). Everything else is scheduling, not parallelism.

  10. 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.

  11. 11What kinds of code block the main thread?

    Anything synchronous and long: a big loop, JSON.parse or JSON.stringify on a huge object, a heavy regex, sorting a hundred thousand rows, synchronous XHR, alert, and in Node the *Sync family like readFileSync.

    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) (or scheduler.yield() where it exists) so the browser gets a turn to render and handle input between slices. Slower overall, but the page stays alive.

  12. 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. 13requestAnimationFrame vs setTimeout - when do you use which?

    requestAnimationFrame for 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. setTimeout for logic on a schedule.

    A setTimeout(fn, 16) drifts against the actual frame boundary, so you get double-paints and dropped frames. rAF is aligned to the frame by definition, and it automatically pauses when the tab is hidden, which is free battery.

    Follow-up Is requestAnimationFrame a 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.

  14. 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 setTimeout that reschedules itself at the END of the work - that guarantees a real gap between runs - and if timing accuracy matters, compute the next delay as start + n * period - Date.now() so the error does not accumulate.

  15. 15Does await block 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 await inside a loop serialises YOUR function without freezing the app.

  16. 16Is the code after an await a microtask?

    Yes. await p schedules the remainder of the function as a promise reaction on p, so it resumes on the microtask queue - one tick later if p is 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 await of a resolved promise resumes in one tick - which is why in the famous ordering puzzle, async1 end now prints before promise2 and did not in 2018.

  17. 17What is process.nextTick and 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.nextTick starves not just tasks but promises too. Node's own guidance is to reach for setImmediate unless 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.

  18. 18Name Node's event loop phases.

    Timers, pending callbacks, idle/prepare, poll, check, close callbacks - and the nextTick plus 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 is setImmediate. The rest is trivia unless you are debugging a native addon.

  19. 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.dispatchEvent is different - that runs the listeners synchronously, right there.

    That asymmetry catches people. dispatchEvent is a plain function call that invokes handlers on the current stack and returns after they finish; a real user click goes through the queue.

  20. 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.

  21. 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) - or await a scheduler.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 queueMicrotask or await 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.

  22. 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.

  23. 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.

  24. 24Two promises resolve at the same time. Which .then runs 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 .then only queues the next link after its own callback returns.

  25. 25Why does returning a promise from a .then cost extra ticks?

    Because adopting a thenable takes two extra microtasks: one to call .then on the returned promise, one to run the resolve callback that follows. So a value returned from .then settles 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.

  26. 26Is an async function's body asynchronous from the first line?

    No. It runs synchronously up to the first await. Everything before that first await happens on the caller's stack, before the caller's next line.

    This matters for validation and for side effects: throwing before the first await still 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.

  27. 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.

  28. 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 nextTick queue that jumps ahead of promises, setImmediate, and the absence of a rendering step.

  29. 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 .catch in the same synchronous block is fine, attaching it in a setTimeout is too late and you get the warning even though the handler eventually arrives.

  30. 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.

  31. 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.

  32. 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. Never setInterval for 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
2

Both 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 timeout

The 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
setTimeout

An 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
p

await 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
macro

micro 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
timer

The 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 timer

By 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
timeout

Three 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
B2

They 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
4

Returning 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 2

process.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
timeout

Inside 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
raf

The 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
6

Sync: 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. queueMicrotask or await 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 * period instead of accumulating the lateness of every previous tick.
  • Returning stop matters: 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 lastArgs means 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.
← Types, coercion and equalityCallbacks, promises and async await →