Node.js NotesRohit’s interview study guide
Chapter 02

The Event Loop

How Node schedules every callback — phases, timers vs setImmediate, nextTick and microtasks, and how to starve it.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Call stack, libuv and the thread pool

Four pieces work together every time your code does something asynchronous:

  1. The call stack (V8) — where JavaScript actually executes, one frame per active function call. There is one stack for your code.
  2. Node's native layer and libuv — when you call an async API, Node's C++ bindings hand the work to libuv, which either asks the operating system to watch a socket or gives the job to its thread pool.
  3. The queues — when the work finishes, libuv records that a callback is ready. There isn't one queue: each kind of callback waits for its own phase, and Node adds two high-priority queues of its own (nextTick and promise microtasks).
  4. The event loop — a loop in libuv's C code that, whenever the stack is empty, picks up ready callbacks and calls them, one at a time, back on the main thread.

The mental model from chapter 1 still holds: the chef (main thread) never waits. The kitchen manager (libuv) does the waiting, sometimes using a few helpers (the thread pool), and passes the chef a ticket (the callback) when the result is ready. The JavaScript site covers the browser version of this picture in The event loop; this chapter is the Node-specific detail.

Here is who does what, step by step, for one file read:

read-demo.cjs
const fs = require('node:fs');

function getStuff() {
  fs.readFile(__filename, (err, data) => {
    if (err) return console.error(err);
    console.log('DONE', data.length);
  });
  console.log('AFTER READ');
}

getStuff();
console.log('getStuff returned');

// AFTER READ
// getStuff returned
// DONE 307
What’s happening
  1. The script calls getStuff(), which is pushed onto the call stack. Stack: [main, getStuff].
  2. getStuff calls fs.readFile, which is pushed too. Node's JavaScript code validates the arguments, creates a native request through the C++ bindings, and libuv queues the actual open/read/close work on its thread pool. Then fs.readFile returns undefined and is popped. The callback is not run; it is stored on the request object.
  3. getStuff is still on the stack and carries on: AFTER READ. Then it returns normally and is popped, then getStuff returned logs and the script ends. Stack: empty.
  4. Meanwhile a pool thread has been reading the file. When it finishes, it tells the loop; the loop, now free because the stack is empty, calls the stored callback on the main thread with err = null and a 307-byte Buffer (this file, comments included): DONE 307.
  5. Note what did not happen: getStuff was never "popped off and delegated" when it made the async call. Only the callback was set aside; everything else ran to completion first.
AdvancedWhich work uses the thread pool

Which work goes where:

OperationWho does the waiting / workThread pool?
TCP/UDP sockets, HTTP servers and clients, pipesThe OS kernel; libuv is notified through epoll (Linux), kqueue (macOS), IOCP (Windows)No — one thread can watch thousands of sockets
Most async fs.* calls (readFile, stat, open…)A libuv pool thread making a blocking system callYes
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (async), async zlibA libuv pool thread doing CPU work in C++Yes
dns.lookup (used by http.get('example.com') by default)A pool thread calling the OS resolver (getaddrinfo)Yes
dns.resolve*c-ares over the networkNo
setTimeout, setInterval, setImmediateNobody — they are entries in data structures the loop checksNo
Promises, await, process.nextTickNobody — they are queues drained on the main threadNo

The pool is small: 4 threads by default. That is easy to see by starting more jobs than threads:

pool.cjs
const crypto = require('node:crypto');
const start = performance.now();

for (let i = 1; i <= 6; i++) {
  crypto.pbkdf2('secret', 'salt', 1_000_000, 64, 'sha512', () => {
    console.log(`hash ${i} done at ${Math.round(performance.now() - start)} ms`);
  });
}
console.log('all 6 started');

// $ node pool.cjs
// all 6 started
// hash 1 done at 195 ms
// hash 2 done at 199 ms
// hash 4 done at 205 ms
// hash 3 done at 206 ms
// hash 5 done at 383 ms
// hash 6 done at 405 ms
//
// $ UV_THREADPOOL_SIZE=6 node pool.cjs
// all 6 started
// hash 6 done at 201 ms
// hash 5 done at 210 ms
// hash 2 done at 213 ms
// hash 3 done at 217 ms
// hash 1 done at 218 ms
// hash 4 done at 223 ms
What’s happening
  1. The loop calls crypto.pbkdf2 six times. Each call queues a job for the thread pool and returns immediately, so all 6 started prints first, within a millisecond.
  2. With the default pool of 4, jobs 1–4 start at once on four threads and each takes ~200 ms of CPU. Jobs 5 and 6 sit in libuv's work queue: there's no free thread.
  3. At ~200 ms the first four finish (in whatever order the threads complete — 4 before 3 here), two threads free up, jobs 5 and 6 start, and they finish at ~400 ms. Two "rounds" of work.
  4. With UV_THREADPOOL_SIZE=6, all six run at once and all finish around 200 ms — in a scrambled order, because six threads race.
  5. The callbacks themselves still run one at a time on the main thread. The pool gives you parallel native work, not parallel JavaScript.
AdvancedTuning UV_THREADPOOL_SIZE

UV_THREADPOOL_SIZE is read once, when the pool is first used, and is capped at 1024 (asking for 2000 gave exactly 1024 pool threads on Node 26). Set it in the environment that starts the process — UV_THREADPOOL_SIZE=8 node server.js or in the Dockerfile. Assigning process.env.UV_THREADPOOL_SIZE at the very top of the entry file also works, but only if nothing has touched the pool yet, which is fragile.

AdvancedEdge case: thread-pool starvation
AdvancedCorrecting my notes: how async calls are delegated

#The phases of the event loop

The event loop is not one queue — it's a cycle of phases, and each phase has its own kind of callback. On every turn ("iteration" or "tick"), libuv visits the phases in a fixed order and runs the callbacks that are ready for that phase.

① Run the main script then drain nextTick + microtasks ② Timers, once due timers before the first turn pending callbacks deferred I/O callbacks e.g. some TCP errors idle, prepare libuv / Node internals no user callbacks poll fs · net · http callbacks sleeps here when idle check setImmediate callbacks right after poll close callbacks socket 'close' events handles being closed timers setTimeout · setInterval after poll (libuv ≥ 1.45) next turn, if anything is still pending; else exit each turn After every single callback — and after the main script 1. process.nextTick queue Node's own queue, drained fully 2. Promise microtasks .then · await · queueMicrotask then repeat until both are empty
Microtask queues are not phases: they run between callbacks, in every phase.

What each phase is for:

  • timers — callbacks of setTimeout and setInterval whose delay has passed. A delay is a minimum, not a promise: "run no earlier than N ms from now". Node turns 0 (and anything below 1) into 1 ms.
  • pending callbacks — a few I/O callbacks that libuv deferred from the previous turn, for example some TCP error reports (ECONNREFUSED on certain Unix systems).
  • idle, prepare — internal bookkeeping for libuv and Node. Nothing of yours runs here.
  • poll — the heart of the loop. libuv asks the OS "which of my sockets and files have events?" and runs their callbacks: incoming HTTP requests, data on a socket, a finished fs.readFile. If there's nothing else to do, the loop sleeps here (inside epoll_wait/kevent) until an event arrives or the nearest timer is due — that's why an idle Node server uses no CPU. If any setImmediate callbacks are waiting, it doesn't sleep at all.
  • check — setImmediate callbacks. It exists precisely so you can say "run this right after the current poll phase".
  • close callbacks — callbacks for handles that are being closed, such as a socket's 'close' event after socket.destroy().

Between all of these, after every individual callback, Node drains the process.nextTick queue and then the promise microtask queue. Those are not phases; they're checkpoints. And when a turn ends with nothing left to wait for — no timers, no open sockets or servers, no pending requests — the loop exits and so does the process. (timer.unref() or server.unref() tells the loop "don't stay alive just for me".)

You can watch a single turn go through the phases in order:

phases.cjs
const net = require('node:net');

const server = net.createServer((socket) => socket.end());
server.listen(0, () => {
  const client = net.connect(server.address().port, () => {
    // We are inside an I/O callback: the poll phase.
    console.log('poll:  connect callback');
    setTimeout(() => console.log('timers: setTimeout 0'), 0);
    setImmediate(() => console.log('check: setImmediate'));
    client.on('close', () => {
      console.log('close: socket close event');
      server.close();
    });
    client.destroy();
    Promise.resolve().then(() => console.log('micro: promise'));
    process.nextTick(() => console.log('micro: nextTick'));
  });
});

// poll:  connect callback
// micro: nextTick
// micro: promise
// check: setImmediate
// close: socket close event
// timers: setTimeout 0
What’s happening
  1. server.listen(0) picks a free port; once listening, net.connect opens a client socket. When the connection is established, libuv sees it in the poll phase and runs the connect callback: poll: connect callback.
  2. Inside that callback we queue one thing for each place: a timer, an immediate, a 'close' listener (and client.destroy(), which starts closing the socket's handle), a promise reaction and a nextTick. Nothing runs yet — we're still inside the callback.
  3. The callback returns, so Node hits a microtask checkpoint: the nextTick queue first (micro: nextTick), then promise jobs (micro: promise).
  4. Poll is done, so the loop moves to check: check: setImmediate. Then close callbacks: the destroyed socket's handle finishes closing and emits 'close'. The listener also closes the server, so the process can exit afterwards.
  5. Finally, timers: the 1 ms timer is due. In 50 out of 50 runs the order was identical — inside an I/O callback, the order of the phases after poll is fixed.
  6. This is the useful way to think about the loop: don't memorise "the loop starts at timers"; ask which phase am I in now, and which phase does each callback belong to?
AdvancedVersion change: timers run after poll

#setTimeout vs setImmediate

From my notes: "Which one executes first, setTimeout or setImmediate?" The answer depends entirely on where you call them from, because that decides which phase the loop is in and which phase it reaches next.

  • setTimeout(fn, 0) means "run fn in a timers phase, once at least 1 ms has passed".
  • setImmediate(fn) means "run fn in the next check phase", which comes right after poll.

Scenario 1, inside an I/O callback — the guaranteed case from my notes:

scenario-1.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
  process.nextTick(() => console.log('process.nextTick'));
});

// process.nextTick
// setImmediate
// setTimeout
// (the same order in 100 out of 100 runs)
What’s happening
  1. The script starts the read and ends. Later, the read completes and the loop runs the callback in the poll phase.
  2. The callback queues a 1 ms timer, an immediate and a nextTick, and returns.
  3. Microtask checkpoint right after the callback: process.nextTick.
  4. The loop is in poll, and the next phase is check: setImmediate. No timing is involved — check always follows poll in the same turn.
  5. Then close callbacks (none), then timers: if 1 ms has passed the timer runs now, otherwise in the next turn. Either way it comes after the immediate: setTimeout. That's the guarantee — "after the microtasks are completed, the check phase comes before the timers phase", exactly as my notes put it.
AdvancedScenario 2: top-level setTimeout vs setImmediate

Scenario 2, at the top level of the main script:

scenario-2.cjs
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('process.nextTick'));

// Always first: process.nextTick. Then, measured on Node 26 (macOS), batches of 100 runs:
//   stdout piped:          setTimeout, setImmediate  in 100 of 100 (and 300 of 300)
//   stdout to a file:      setTimeout first 77, 92 and 100 times in three batches
//   without the nextTick:  setImmediate first 97–99 times
What’s happening
  1. The script registers a timer at time T, which becomes due at T + 1 ms; then an immediate; then a nextTick. The script ends.
  2. Microtask checkpoint: the nextTick callback runs and prints. This is the first console.log of the process, and the first one is expensive because it creates the stdout stream: about 1.3 ms when stdout is a pipe, about 0.7 ms when it's a file.
  3. Now the loop starts, with its one timers pass before the first turn (box ② in the diagram). The question is simply: has 1 ms passed since T? If yes, setTimeout runs now, then poll (which doesn't wait, because an immediate is pending), then check runs setImmediate.
  4. If less than 1 ms has passed, the timers pass finds nothing due, poll doesn't wait, check runs setImmediate, and the timer runs in the timers phase at the end of that turn.
  5. So the order is a race against 1 ms of whatever happens between the setTimeout call and the loop starting. With a pipe, the console start-up always took more than 1 ms, so the timer always won; with a file it hovered around 1 ms, so the result changed from batch to batch with machine load; without the nextTick's console.log, almost nothing happens before the loop starts, so the immediate almost always won.
  6. The honest answer is the one in my notes — the order may vary — and the practical rule is: never rely on it. If you need an order, schedule one from the other's callback, or do both from inside an I/O callback.
AdvancedMeasured: order from every calling context

The same reasoning predicts every other context. Each row was run 200 times:

Called fromPhase you're inResultWhy
An I/O callback (fs, net, http)pollsetImmediate first, alwayscheck is the very next phase
A setTimeout callbacktimerssetImmediate first (200/200)the new timer is due 1 ms later; the next phases are pending → poll → check
A setImmediate callbackcheckVaries: immediate first 194/200the new immediate waits for the next check; the timer runs at the end of this turn only if 1 ms has passed by then
The main scriptbefore the loopVaries (see above)race against 1 ms before the first timers pass
AdvancedCorrecting my notes: setTimeout doesn't always win

#process.nextTick and the microtask queues

Node has two high-priority queues that sit outside the phases:

  • The nextTick queue — Node's own queue, filled by process.nextTick(fn).
  • The promise microtask queue — V8's queue, filled by promise reactions (.then, .catch, .finally), await continuations, and queueMicrotask(fn).

From my notes: "Microtasks (process.nextTick and Promise.resolve())… are executed after each phase before moving to the next one." The real rule is stronger: after every callback the loop runs — every timer, every immediate, every I/O callback — and after the main script, Node runs this algorithm:

  1. Drain the nextTick queue completely (including ticks added while draining).
  2. Drain the promise microtask queue completely (including microtasks added while draining).
  3. If step 2 added new nextTicks, go back to step 1. Otherwise, let the loop continue.

A mental model: two express lanes at the till. The cashier serves the nextTick lane until it's empty, then the promise lane until it's empty, and only then the next customer from the normal queue. But once the cashier has started on the promise lane, they finish it — a newcomer in the nextTick lane waits until the promise lane is empty. The JavaScript site's Microtasks vs macrotasks covers the promise side in general.

The basic order, in CommonJS and in an ES module:

order.cjs
console.log('A');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('B');

// node order.cjs  →  A, B, nextTick, promise, timeout
// node order.mjs  →  A, B, promise, nextTick, timeout   (same code, saved as .mjs)
What’s happening
  1. Both versions log A and B synchronously and queue three callbacks: a timer (timers phase), a promise reaction (microtask queue), a nextTick (nextTick queue).
  2. CommonJS: the main script is a plain function call from Node's bootstrap. When it returns, Node runs its algorithm: nextTick queue first → nextTick, then promise jobs → promise. Then the loop starts and the timer runs → timeout.
  3. ES module: Node evaluates the module from inside its async module loader, so the module body runs while V8 is already in the middle of draining promise jobs. The .then callback is just another job in that same drain → promise. Only when V8's queue is empty does control get back to Node, which then drains its nextTick queue → nextTick.
  4. In both, the timer comes last: microtasks of both kinds always run before the loop moves on to a phase.
  5. Verified 30 times each on Node 26 — stable in each mode, but different between modes. This is why every example on this site is named .cjs or .mjs: the module system changes the answer (more in CommonJS vs ES modules).
AdvancedNested ticks and promises: exact order

The ESM difference only affects code at the top level of a module. Inside any callback the loop calls — a timer, an I/O callback — .mjs behaves like .cjs: a setTimeout callback that queues a promise and a nextTick prints nextTick, promise in an .mjs file too.

Nesting is where the algorithm really matters:

nested.cjs
Promise.resolve().then(() => {
  console.log('then 1');
  process.nextTick(() => console.log('nextTick from then 1'));
}).then(() => console.log('then 2'));
Promise.resolve().then(() => console.log('then 3'));
process.nextTick(() => {
  console.log('nextTick 1');
  Promise.resolve().then(() => console.log('promise from nextTick 1'));
  process.nextTick(() => console.log('nextTick from nextTick 1'));
});

// nextTick 1
// nextTick from nextTick 1
// then 1
// then 3
// promise from nextTick 1
// then 2
// nextTick from then 1
What’s happening
  1. After the script: nextTick queue [nextTick 1], promise queue [then 1, then 3] (then 2 is attached to the promise then 1 returns, so it isn't queued yet).
  2. Drain nextTicks: nextTick 1 runs, queues promise from nextTick 1 (promise queue [then 1, then 3, promise from nextTick 1]) and nextTick from nextTick 1. Draining means until empty, so that new tick runs too → nextTick from nextTick 1.
  3. Drain promise jobs: then 1 runs and queues a nextTick (nextTick from then 1 — it has to wait). When then 1 returns, its promise fulfils, which queues then 2 at the back. Then then 3, promise from nextTick 1, then 2. V8 doesn't stop until its queue is empty.
  4. Back in Node's loop: the nextTick queue isn't empty, so drain it → nextTick from then 1.
  5. The surprising line is the last one: a nextTick queued inside a .then ran after then 2, which was queued later. nextTick only has priority between drains; it can't cut into a promise drain that's already running.
  6. As nested.mjs the output is then 1, then 3, then 2, nextTick 1, nextTick from then 1, nextTick from nextTick 1, promise from nextTick 1 — the top-level module is already inside a promise drain, so all three thens go first.
Advancedprocess.nextTick inside .then()

My notes had a dedicated point about this: "process.nextTick inside the .then()". Here it is in the scenario my notes used it in, inside an I/O callback with a timer and an immediate also pending:

tick-in-then.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));

  Promise.resolve()
    .then(() => {
      console.log('then 1');
      process.nextTick(() => console.log('nextTick (queued inside then 1)'));
    })
    .then(() => console.log('then 2'));

  process.nextTick(() => console.log('nextTick'));
});

// nextTick
// then 1
// then 2
// nextTick (queued inside then 1)
// setImmediate
// setTimeout
// (50 of 50 runs; identical as .mjs, because this is inside a callback)
What’s happening
  1. The read finishes and its callback runs in the poll phase. It queues a timer, an immediate, then 1 (promise queue) and a nextTick, and returns.
  2. Checkpoint, nextTick queue first: nextTick.
  3. Promise queue: then 1 runs and queues a nextTick; its promise fulfils, queueing then 2, which runs in the same drain → then 2.
  4. Node checks its nextTick queue again and finds the one queued inside then 1 → nextTick (queued inside then 1). It ran before the loop moved on, so it still beat every phase.
  5. Then the phases: check → setImmediate, later timers → setTimeout. The lesson from my notes holds: nextTick has higher priority than promises when both are waiting, and both queues are emptied before the loop continues to the check and timers phases.
AdvancedProof: microtasks drain after each callback

Finally, the proof that the checkpoint happens after each callback, not after each phase:

per-callback.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setImmediate(() => {
    console.log('immediate 1');
    Promise.resolve().then(() => console.log('  promise queued by immediate 1'));
    process.nextTick(() => console.log('  nextTick queued by immediate 1'));
  });
  setImmediate(() => console.log('immediate 2'));

  setTimeout(() => {
    console.log('timeout 1');
    Promise.resolve().then(() => console.log('  promise queued by timeout 1'));
  }, 0);
  setTimeout(() => console.log('timeout 2'), 0);
});

// immediate 1
//   nextTick queued by immediate 1
//   promise queued by immediate 1
// immediate 2
// timeout 1
//   promise queued by timeout 1
// timeout 2
What’s happening
  1. From the poll phase we queue two immediates and two timers. Doing it inside an I/O callback makes the order deterministic: check before timers.
  2. Check phase: immediate 1 runs and queues a promise job and a nextTick.
  3. If microtasks only ran "after each phase", immediate 2 would print next. Instead Node runs the checkpoint straight after immediate 1: the nextTick, then the promise.
  4. Only then immediate 2. The same happens in the timers phase: timeout 1's promise runs before timeout 2.
  5. This per-callback behaviour has been Node's since Node 11 and matches browsers. Before Node 11, all the immediates (or all the expired timers) of a phase ran first and microtasks ran between phases — which is the model my notes describe.
AdvancedCorrecting my notes: when microtasks run
AdvancedAlternative: queueMicrotask

#Real-world uses of process.nextTick

If microtask ordering is so subtle, why does process.nextTick exist? Because a lot of Node's own API design depends on one guarantee it gives: "run this after the current code has finished, but before anything else happens" — before any I/O, any timer, and (in CommonJS) any promise. Node core uses it everywhere, and there are two patterns you should know.

1. Make a callback API consistently asynchronous. An API whose callback is sometimes synchronous and sometimes asynchronous is a classic bug source (nicknamed "releasing Zalgo"). A cache is the usual culprit:

zalgo.cjs
const cache = new Map();
const db = { getUser: (id, cb) => setTimeout(() => cb(null, { id, name: 'Neha' }), 10) };

function getUserBad(id, callback) {
  if (cache.has(id)) return callback(null, cache.get(id)); // sync on a hit
  db.getUser(id, (err, user) => {                            // async on a miss
    if (!err) cache.set(id, user);
    callback(err, user);
  });
}

function getUserGood(id, callback) {
  if (cache.has(id)) {
    process.nextTick(callback, null, cache.get(id));         // always async
    return;
  }
  db.getUser(id, (err, user) => {
    if (!err) cache.set(id, user);
    callback(err, user);
  });
}

function caller(getUser, label) {
  let ready = false;
  getUser(7, (err, user) => console.log(`${label}: callback sees ready = ${ready}`));
  ready = true; // setup the caller does after starting the request
}

caller(getUserBad, 'bad,  miss');
setTimeout(() => {          // by now user 7 is cached
  caller(getUserBad, 'bad,  hit ');
  caller(getUserGood, 'good, hit ');
}, 50);

// bad,  miss: callback sees ready = true
// bad,  hit : callback sees ready = false
// good, hit : callback sees ready = true
What’s happening
  1. caller follows a common shape: start an operation, then finish some setup (ready = true) before the result can possibly arrive. That's only safe if the callback is never called synchronously.
  2. First call, cache miss: getUserBad goes to the fake DB (10 ms). ready = true runs, and 10 ms later the callback sees ready = true. User 7 is now cached.
  3. At 50 ms, cache hit: getUserBad calls the callback immediately, inside getUser, before caller has reached ready = true. It sees ready = false — same function, same arguments, different behaviour depending on cache state. In real code this shows up as "works in tests, breaks when the cache is warm".
  4. getUserGood on a hit uses process.nextTick(callback, null, user): the callback runs after caller has finished, but before any I/O or timer, so there's almost no delay. It sees ready = true, just like a miss.
  5. The rule: a callback API should be always synchronous or always asynchronous, never both. A promise-returning API gets this for free, because .then callbacks are always asynchronous.
AdvancedUse 2: emitting events after the constructor

2. Emit an event after the constructor returns, so the caller has a chance to attach listeners:

ready-event.cjs
const { EventEmitter } = require('node:events');

class EagerService extends EventEmitter {
  constructor() {
    super();
    this.emit('ready');                          // nobody is listening yet
  }
}

class DeferredService extends EventEmitter {
  constructor() {
    super();
    process.nextTick(() => this.emit('ready'));  // after the caller's code
  }
}

new EagerService().on('ready', () => console.log('eager: ready'));
new DeferredService().on('ready', () => console.log('deferred: ready'));
console.log('end of script');

// end of script
// deferred: ready
What’s happening
  1. new EagerService() runs its constructor, which emits 'ready' straight away. emit is synchronous and there are no listeners yet, so the event is simply lost.
  2. .on('ready', …) is attached to the returned object — too late. eager: ready never prints.
  3. new DeferredService() only schedules the emit. The constructor returns, .on('ready', …) attaches the listener, end of script logs.
  4. The script finishes, the nextTick runs, and emit('ready') finds the listener → deferred: ready.
  5. The same idea applies to errors: if a constructor or a factory detects a problem immediately, emitting 'error' on nextTick gives the caller time to attach an 'error' listener (otherwise an unhandled 'error' event throws — see The error event).
AdvancedNode core example: server.listen

Node core does exactly this. server.listen() starts listening synchronously but emits 'listening' later, so this order is safe:

listen.cjs
const net = require('node:net');
const server = net.createServer();
server.listen(0);                                   // starts listening now…
console.log('listening already?', server.listening);
server.on('listening', () => {                      // …but the event waits
  console.log('listening event, port', typeof server.address().port);
  server.close();
});

// listening already? true
// listening event, port number
What’s happening
  1. server.listen(0) binds the socket synchronously: server.listening is already true on the next line.
  2. But the 'listening' event hasn't fired yet — Node deferred it, so the listener registered after listen() still receives it.
  3. Streams follow the same rule: stream.destroy(err) and many errors from fs.createReadStream are emitted asynchronously so you can attach 'error' handlers right after creating the stream.
AdvancedOther places Node uses nextTick

Other places you'll meet it: stream implementations deferring 'end' or 'finish'; libraries normalising sync and async code paths; and cleanup that must happen "after this function returns, but before anything else".

AdvancedPitfall: heavy work in nextTick

#process.nextTick vs setImmediate

From my notes: "nextTick vs setImmediate (Wonderful!)". The two names are famously backwards:

  • process.nextTick(fn) does not wait for the next tick of the loop. It runs fn as soon as the current callback (or the main script) finishes — before the loop does anything else.
  • setImmediate(fn) is not immediate. It runs fn in the next check phase, after the loop has polled for I/O.

Node's own docs admit it: the names "should be swapped", but changing them would break much of npm, so they stay.

next-vs-immediate.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setImmediate(() => console.log('setImmediate'));
  process.nextTick(() => console.log('process.nextTick'));
  console.log('sync code in the I/O callback');
});

// sync code in the I/O callback
// process.nextTick
// setImmediate
What’s happening
  1. The I/O callback runs in the poll phase and schedules an immediate first, then a nextTick, then logs synchronously → sync code in the I/O callback.
  2. The callback returns. Before the loop takes another step, Node drains the nextTick queue → process.nextTick, even though it was scheduled second.
  3. Poll ends and the check phase runs → setImmediate.
  4. Between those two lines, in a busy server, the loop could have run other I/O callbacks in the same poll phase. That's the difference that matters: nextTick cuts in line; setImmediate gives the loop a turn.
AdvancedReference: nextTick vs setImmediate
process.nextTick(fn)setImmediate(fn)
RunsRight after the current callback, before the loop continuesIn the next check phase (after poll)
QueueNode's nextTick queue, drained completelyThe immediate queue, one batch per loop turn
Scheduled from inside itselfRuns in the same drain — can repeat foreverRuns in the next turn — I/O and timers get a go in between
Can starve I/O?YesNo
Relative to promisesBefore them (CommonJS), between drainsAlways after them
Typical useTiny deferrals: consistent async callbacks, emit after constructorYielding between chunks of work, "after current I/O"
Browser equivalentNone (queueMicrotask is closest)None standard (setTimeout(fn, 0) or MessageChannel)

Node's docs recommend using setImmediate in all cases where either would do, because it's easier to reason about — and it can't starve the loop.

#Starvation and stack overflow

My notes list two separate warnings: "Avoid overloading the call stack and resulting in a stack overflow exception" and "Too many microtasks can also lead to starvation". They are opposite problems, and fixing one can cause the other.

Stack overflow is about depth: each nested call adds a frame to the call stack, and the stack has a fixed size.

overflow.cjs
let depth = 0;
function recurse() {
  depth++;
  recurse();          // every call adds a stack frame; none ever returns
}
try {
  recurse();
} catch (err) {
  console.log(err.constructor.name + ':', err.message);
  console.log('frames before overflow:', depth);
}

// RangeError: Maximum call stack size exceeded
// frames before overflow: 10398
What’s happening
  1. recurse() calls itself before returning, so the stack grows by one frame per call: recurse → recurse → recurse → …. Nothing is ever popped.
  2. V8's stack has a fixed budget (984 KB by default — node --v8-options lists it; tunable with node --stack-size=<KB>, carefully, because setting it above the OS thread's real stack size crashes the process). After 10398 frames of this small function it runs out and throws a RangeError.
  3. The error is catchable, so the catch prints it and the process continues. The exact depth depends on frame size — bigger functions with more locals overflow sooner — and on the flag: with --stack-size=2000 the same code reached 21,235 frames.
  4. Real causes: unbounded recursion on deep or cyclic data (a tree walk, a naive deep clone of an object with a cycle), or mutual recursion between two functions.
AdvancedDeferred recursion and starvation

The usual "fix" is to defer the recursive call, so each call starts on a fresh stack. With process.nextTick that removes the overflow — and creates starvation. Here a timer is scheduled first and given 0 ms; a safety stop ends each demo after 500 ms:

starve.cjs
const kind = process.argv[2];              // 'nextTick' | 'promise' | 'immediate'
const start = Date.now();
let runs = 0;

setTimeout(() => {
  console.log(`${kind}: timer fired after ${runs} runs, ${Date.now() - start} ms`);
}, 0);

function loop() {
  runs++;
  if (Date.now() - start > 500) {          // safety stop so the demo ends
    console.log(`${kind}: safety stop after ${runs} runs`);
    return;
  }
  if (kind === 'nextTick') process.nextTick(loop);
  else if (kind === 'promise') Promise.resolve().then(loop);
  else setImmediate(loop);
}
loop();

// $ node starve.cjs nextTick
// nextTick: safety stop after 11482058 runs
// nextTick: timer fired after 11482058 runs, 502 ms
//
// $ node starve.cjs promise
// promise: safety stop after 15864283 runs
// promise: timer fired after 15864283 runs, 502 ms
//
// $ node starve.cjs immediate
// immediate: timer fired after 43 runs, 1 ms
// immediate: safety stop after 36561 runs
What’s happening
  1. The timer is registered first, due after 1 ms. Then loop() runs once synchronously and schedules the next loop.
  2. nextTick: after the script, Node drains the nextTick queue. Every loop adds another tick, so the queue is never empty and the drain never ends. The stack doesn't grow — each loop returns before the next one runs — so there's no overflow, but the loop never reaches the timers phase. 11.5 million runs later the safety stop breaks the chain, and only then does the timer fire, 500 ms late.
  3. promise: exactly the same, with V8's microtask queue: 15.9 million runs, timer 500 ms late. Promise recursion starves just as well; only the queue differs.
  4. immediate: each loop is scheduled for the next check phase. Between check phases the loop goes through close, timers, pending and poll, so as soon as the timer is due (1 ms) it runs: after only 43 runs. The chain then continues for the rest of the 500 ms, with I/O and timers getting a turn on every lap.
  5. Without the safety stop, the nextTick and promise versions would hang forever: no timer, no I/O, no incoming request would ever be handled, while the CPU sits at 100%.
AdvancedReference: stack overflow vs starvation
Stack overflowStarvation
CauseToo many nested synchronous callsHigh-priority callbacks that keep scheduling more of themselves
StackGrows until it runs outUnwinds normally each time
SymptomRangeError: Maximum call stack size exceededNo error; timers, I/O and requests just never run
ExampleUnbounded recursionRecursive process.nextTick, recursive .then, queueMicrotask
FixIterate instead of recursing; an explicit stack; bound the depthYield with setImmediate; chunk the work; move it to a worker
AdvancedSpotting starvation in production

#Yielding to the event loop with setImmediate

Sometimes you do have a big chunk of CPU work on the main thread — transforming 100,000 rows, building a report — that is too small to justify a worker, but too long to run in one go. The fix is cooperative multitasking: do a slice of the work, give the loop a turn, then continue. setImmediate is the right tool because it lets timers and I/O run between slices without adding the 1 ms minimum delay of setTimeout.

This is the pattern from the study guide, with a 10 ms interval standing in for "everything else the server needs to do":

chunking.cjs
const items = Array.from({ length: 1_000_000 }, (_, i) => i);
let total = 0;
function doWork(n) {                                // stand-in for real per-item work
  for (let k = 0; k < 100; k++) total += Math.sqrt(n + k);
}

const start = Date.now();
let ticks = 0;
const ticker = setInterval(() => ticks++, 10);      // other work that needs the thread

function processItems(items, index = 0) {
  const end = Math.min(index + 50_000, items.length);
  for (let i = index; i < end; i++) doWork(items[i]);
  if (end < items.length) {
    setImmediate(() => processItems(items, end));   // yield, then continue
  } else {
    clearInterval(ticker);
    console.log(`chunked:  ${Date.now() - start} ms, interval ran ${ticks} times`);
  }
}

if (process.argv[2] === 'sync') {
  for (const item of items) doWork(item);           // one long synchronous loop
  clearInterval(ticker);
  console.log(`one loop: ${Date.now() - start} ms, interval ran ${ticks} times`);
} else {
  processItems(items);
}

// $ node chunking.cjs sync
// one loop: 352 ms, interval ran 0 times
// $ node chunking.cjs
// chunked:  354 ms, interval ran 19 times
What’s happening
  1. Sync run. One for loop processes all 1,000,000 items in 352 ms. The interval wants to run every 10 ms, but the stack is never empty, so it runs 0 times. In a server, every request that arrived during those 352 ms waited.
  2. Chunked run. processItems(items, 0) handles items 0–49,999 (about 17 ms), then schedules itself with setImmediate for index 50,000 and returns. The stack empties.
  3. The loop gets a turn: it finishes the check phase, runs due timers (the interval), polls for I/O, and reaches the next check phase, where the next slice runs. That repeats 20 times.
  4. The interval ran 19 times — roughly once per slice — and the total time barely changed (354 ms). Same work, but nothing waits more than one slice (~17 ms).
  5. Why not setTimeout(…, 0)? It works, but each slice then waits at least 1 ms: the same run took ~373 ms with setTimeout. Why not process.nextTick or a promise? They would run each slice before the loop's next turn, starving it exactly like the sync loop.
  6. Choosing the slice size is the trade-off: smaller slices mean better latency for everyone else but more overhead; aim for a few milliseconds per slice.
AdvancedAsync chunking with timers/promises

With async/await, the promise version of setImmediate from node:timers/promises makes the same pattern read like a plain loop:

chunk-async.mjs
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function sumOfRoots(count, chunkSize = 50_000) {
  let total = 0;
  for (let i = 0; i < count; i++) {
    for (let k = 0; k < 100; k++) total += Math.sqrt(i + k);
    if (i % chunkSize === chunkSize - 1) await yieldToLoop(); // let timers and I/O run
  }
  return total;
}

const start = Date.now();
let ticks = 0;
const ticker = setInterval(() => ticks++, 10);
const total = await sumOfRoots(1_000_000);
clearInterval(ticker);
console.log(`total ${total.toExponential(3)} in ${Date.now() - start} ms, interval ran ${ticks} times`);

// total 6.667e+10 in 55 ms, interval ran 5 times
What’s happening
  1. sumOfRoots runs synchronously for 50,000 items, then hits await yieldToLoop(). That returns a promise that resolves in the next check phase, so the function suspends and the loop gets its turn.
  2. In the check phase the promise resolves; the await continuation runs as a microtask straight after, and the next 50,000 items are processed.
  3. The interval ran 5 times in 55 ms — it never waited more than one slice. (This version is much faster than the previous one because total is a local variable, which V8 optimises better than the shared total that doWork updated through a closure.)
  4. Top-level await is why this file is .mjs. scheduler.yield() from the same module behaves the same way — its promise also resolves in the check phase.
AdvancedBeyond chunking: offloading CPU work

#How one process serves many requests

This is where the event loop pays off. A route that spends 2 ms running JavaScript and 300 ms waiting for a database only needs the thread for those 2 ms. While it waits, the same thread can run other requests. Three users — Rohit, Neha and Romeo — hitting the same route at once look like this:

Timeline (one thread)
Request A: JS → [DB waiting.........] → JS response
Request B:   JS → [DB waiting.........] → JS response
Request C:     JS → [DB waiting.........] → JS response
The waits overlap; the short JS segments take turns on the main thread.
AdvancedRunning it: concurrent requests in one process

Here it is running, with a fake database that waits 300 ms (the Express app and three fetch calls run in the same process, which is fine because both sides are non-blocking; Express 5 in the sandbox):

many-users.cjs
const express = require('express');
const { setTimeout: sleep } = require('node:timers/promises');

let t0;
const log = (msg) => console.log(`${String(Date.now() - t0).padStart(4)} ms  ${msg}`);

// Illustrative "database": each query waits 300 ms without using the JS thread.
const db = { getUser: async (id) => { await sleep(300); return { id }; } };

const app = express();
app.get('/users/:id', async (req, res, next) => {
  try {
    log(`start  ${req.params.id}`);
    const user = await db.getUser(req.params.id);  // yields the thread
    log(`finish ${req.params.id}`);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

const server = app.listen(0, async () => {
  t0 = Date.now();
  const base = `http://localhost:${server.address().port}`;
  await Promise.all(['rohit', 'neha', 'romeo'].map((id) => fetch(`${base}/users/${id}`)));
  log('all three responses received');
  server.close();
});

//   18 ms  start  neha
//   18 ms  start  rohit
//   19 ms  start  romeo
//  320 ms  finish neha
//  322 ms  finish rohit
//  323 ms  finish romeo
//  326 ms  all three responses received
What’s happening
  1. Three fetch calls open three connections at once. The first ~18 ms is connection set-up and the first request through Express; the arrival order varies between runs (Neha's arrived first here).
  2. Each request arrives the same way: packets reach the OS socket; in the poll phase libuv sees the socket is readable; Node's HTTP parser (llhttp) parses the request and emits 'request'; Express runs its middleware and the route handler — all on the main thread.
  3. The handler logs start and calls db.getUser, which starts a 300 ms wait. await suspends the handler and returns. The thread is free, so the next request's handler runs a fraction of a millisecond later. All three are "in flight" by 19 ms.
  4. Around 320 ms the three waits end, one after the other. Each one fulfils a promise, the await continuation runs as a microtask, logs finish and calls res.json, which writes to the socket (again via libuv).
  5. Total ~326 ms for three 300 ms requests, instead of ~900 ms. The waits overlapped; only the short JavaScript segments took turns. The event loop mattered twice per request: when it arrived and when the database answered.
AdvancedThe real scaling limit: main-thread time

The scaling limit, then, isn't the number of connections — it's how much main-thread time each request needs. At 2 ms of JavaScript per request, one process can handle on the order of 500 requests per second however long the database takes, as long as the database itself keeps up. Beyond that you add processes (cluster or more containers) — see Scaling to thousands of concurrent requests.

#Blocking the event loop

The flip side: anything that keeps the main thread busy stops every request in that process. The event loop can only pick up the next callback when the current one returns. A handler that computes for 2 seconds is 2 seconds in which no other handler, timer or I/O callback runs.

The example from the study guide, with a 2-second busy loop:

blocking.cjs
const express = require('express');

const app = express();
app.get('/bad', (req, res) => {
  const start = Date.now();
  while (Date.now() - start < 2000) {
    // Deliberately blocks the main thread for 2 seconds.
  }
  res.send('done');
});
app.get('/hello', (req, res) => res.send('hello'));

app.listen(3000, () => console.log('listening on 3000'));
What’s happening
  1. Started with node blocking.cjs and tested from another terminal with curl: /hello on its own answers in 0.006 s.
  2. curl /bad starts, and 0.1 s later curl /hello. The /bad handler is running its while loop on the main thread. /bad took 2.000 s.
  3. The /hello request reached the machine on time: the OS accepted the connection and buffered the bytes. But reading that socket, parsing the HTTP and calling the handler all need the main thread, which is stuck in the while. /hello took 1.89 s — the rest of /bad's 2 seconds — for a handler that takes microseconds.
  4. With 100 concurrent users, one call to /bad adds up to 2 seconds of latency to all of them. That's why "don't block the event loop" is the first rule of Node performance.
AdvancedMisconception: await creates a thread

A common misconception is that async or await moves work off the thread. They don't — await creates no thread. The awaited expression is evaluated first, synchronously:

await-blocks.cjs
function expensiveSynchronousCalculation() {
  const end = Date.now() + 300;
  while (Date.now() < end) {}
  return 42;
}

async function handler() {
  const result = await expensiveSynchronousCalculation(); // runs BEFORE await
  return result;
}

const t0 = Date.now();
setTimeout(() => console.log(`timer due at 0 ms ran at ${Date.now() - t0} ms`), 0);
handler().then((r) => console.log(`handler resolved with ${r} at ${Date.now() - t0} ms`));
console.log(`handler() returned at ${Date.now() - t0} ms`);

// handler() returned at 300 ms
// handler resolved with 42 at 301 ms
// timer due at 0 ms ran at 301 ms
What’s happening
  1. handler() starts running synchronously, as every async function does. To evaluate await expensiveSynchronousCalculation(), JavaScript must first call the function, which busy-waits for 300 ms on the main thread.
  2. Only when it returns 42 does await happen: it suspends handler and queues the continuation as a microtask. handler() returns its pending promise — at 300 ms.
  3. After the script, the microtask runs: the continuation returns 42, the promise fulfils, .then logs at 301 ms.
  4. The timer, due at 1 ms, runs last at 301 ms: it was blocked the whole time. Marking the function async changed when the result is delivered, not where the work runs. The fixes are the ones from chapter 1: chunk it, or move it to a worker thread.
AdvancedMeasuring loop delay with monitorEventLoopDelay

You can't fix what you can't see, so measure it. perf_hooks.monitorEventLoopDelay samples how late the loop is:

eld.cjs
const { monitorEventLoopDelay } = require('node:perf_hooks');

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const block = (ms) => { const end = Date.now() + ms; while (Date.now() < end) {} };
const timer = setInterval(() => block(200), 500);   // a 200 ms stall every 500 ms

setTimeout(() => {
  clearInterval(timer);
  h.disable();
  const ms = (ns) => (ns / 1e6).toFixed(1);
  console.log(`p50 ${ms(h.percentile(50))} ms, p99 ${ms(h.percentile(99))} ms, max ${ms(h.max)} ms`);
}, 2100);

// p50 12.0 ms, p99 208.9 ms, max 212.1 ms
What’s happening
  1. The histogram schedules an internal timer every resolution (10 ms) and records how long each interval actually took, in nanoseconds.
  2. Every 500 ms, the interval callback blocks the thread for 200 ms; the rest of the time the loop is idle.
  3. The p50 of 12 ms is the idle case: the 10 ms sampling interval itself plus ~2 ms of timer slack (an idle run on its own also gives p50 ≈ 12 ms, so subtract the resolution to get the real delay).
  4. The max of 212 ms is the 200 ms stall showing through. In production you'd export p99 and max as metrics and alert when they climb. Chapter 10 goes deeper in Measuring event-loop delay, and shows how to find the culprit with a CPU profile.
AdvancedWays to block the loop without a while

Typical ways to block the loop without writing a while loop: synchronous APIs on the request path (readFileSync, execSync, crypto.pbkdf2Sync, zlib.gzipSync), huge JSON.parse/JSON.stringify, regexes with catastrophic backtracking, sorting or filtering big arrays in a handler, and accidental O(n²) loops. Chapter 10's Avoiding synchronous work on hot paths has the checklist.

#Predict the output: event loop puzzles

Added

Each puzzle was run at least 100 times on Node 26. Work out the answer, writing down the queues as you go — nextTick queue, promise queue, timers, immediates — before opening it.

Puzzle 1: everything at the top level (CommonJS)
q1.cjs
console.log('1');
setTimeout(() => console.log('2'), 0);
setImmediate(() => console.log('3'));
Promise.resolve().then(() => console.log('4'));
process.nextTick(() => console.log('5'));
queueMicrotask(() => console.log('6'));
console.log('7');
Show answer

1 7 5 4 6, then either 3 2 or 2 3. In 200 runs: 3 2 171 times, 2 3 29 times.

What’s happening
  1. Synchronous: 1, then four callbacks are queued — timer [2], immediates [3], promise queue [4, 6] (queueMicrotask joins the same queue as .then, in order), nextTick queue [5] — then 7.
  2. Script done (CommonJS), so nextTicks first → 5, then promise jobs in FIFO order → 4, 6.
  3. Now the race from setTimeout vs setImmediate: is the 1 ms timer due by the first timers check? This time the expensive first console.log happened at line 1, before setTimeout was called, so little time passes between the setTimeout call and the loop starting — the immediate usually wins (3 2), but not always (2 3 about 15% of the time).
  4. A good interview answer is exactly this: "1 7 5 4 6, and then 2 and 3 in either order — and here's why".
Puzzle 2: inside an I/O callback
q2.cjs
const fs = require('node:fs');

fs.readFile(__filename, () => {
  console.log('A');
  setTimeout(() => console.log('B'), 0);
  setImmediate(() => {
    console.log('C');
    process.nextTick(() => console.log('D'));
  });
  setImmediate(() => console.log('E'));
  Promise.resolve().then(() => {
    console.log('F');
    setImmediate(() => console.log('G'));
  });
  process.nextTick(() => console.log('H'));
});
Show answer

A H F C D E G B — the same in 200 of 200 runs.

What’s happening
  1. The callback runs in the poll phase → A. It queues: timer [B], immediates [C, E], promise queue [F], nextTick queue [H].
  2. Checkpoint after the callback: nextTick → H; promise → F, which queues another immediate: immediates [C, E, G]. It was queued before the check phase started, so it joins this turn's batch.
  3. Check phase: C runs and queues nextTick D. The checkpoint after C runs D before the next immediate → C D.
  4. Then E, then G.
  5. Close callbacks (none), then timers → B. Every immediate, even one queued from a promise, beat the 0 ms timer, because the I/O callback put us in poll and check comes first.
  6. If G had been queued from inside an immediate (say from C), it would wait for the next turn's check phase — after B.
Puzzle 3: the same file as .cjs and .mjs
q3.cjs
setTimeout(() => console.log('timeout'), 0);
process.nextTick(() => console.log('tick 1'));
Promise.resolve()
  .then(() => {
    console.log('promise 1');
    process.nextTick(() => console.log('tick 2'));
  })
  .then(() => console.log('promise 2'));
console.log('sync');

What does it print as q3.cjs, and as q3.mjs?

Show answer
  • q3.cjs: sync, tick 1, promise 1, promise 2, tick 2, timeout
  • q3.mjs: sync, promise 1, promise 2, tick 1, tick 2, timeout

Both stable over 200 runs.

What’s happening
  1. Both: sync first. Queues: timer [timeout], nextTick [tick 1], promises [promise 1] (promise 2 waits for promise 1's promise).
  2. CommonJS: the script returns to Node, which drains nextTicks → tick 1; then promise jobs → promise 1 (queues tick 2, which must wait), its promise fulfils so promise 2 is queued and runs in the same drain → promise 2. Back in Node: nextTick queue has tick 2 → tick 2.
  3. ES module: the module body is evaluated inside a promise job, so when it finishes V8 is still draining: promise 1 (queues tick 2), then promise 2. Only then does Node drain its nextTick queue, where tick 1 was queued first → tick 1, tick 2.
  4. Both: the timer runs last, once the loop starts.
  5. Notice that tick 2 never cuts in front of promise 2 in either mode: once a promise drain has started, nextTicks wait for it to finish.
Puzzle 4: async function inside a timer
q4.cjs
async function task() {
  console.log('task start');
  await null;
  console.log('after await');
  setImmediate(() => console.log('immediate'));
  process.nextTick(() => console.log('tick'));
}

setTimeout(() => {
  console.log('timeout');
  task().then(() => console.log('task done'));
  console.log('timeout end');
}, 0);
Show answer

timeout, task start, timeout end, after await, task done, tick, immediate — 200 of 200 runs.

What’s happening
  1. The only thing queued by the script is the timer. When it fires (timers phase) → timeout.
  2. task() runs synchronously up to its first await → task start. await null queues the continuation on the promise queue and task() returns a pending promise; .then(task done) is attached to it. Back in the timer callback → timeout end.
  3. The timer callback returns; checkpoint. nextTick queue is empty, so drain promises: the continuation → after await, which queues an immediate and a nextTick. task then returns, fulfilling its promise, which queues task done on the promise queue — and V8 keeps draining → task done.
  4. Promise queue empty; Node checks nextTicks again → tick. It was queued before task done, but it was queued from inside the promise drain, so it waited.
  5. The loop continues from timers through poll to check → immediate. A setImmediate scheduled from a timer always comes after all of that timer's microtasks, and in the same loop turn.

If you can explain all four out loud — phase, queue, and why — you can handle any event-loop question in an interview. The review chapter at the end of the site has more event-loop questions.

Test yourself: event loop and async questions

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.