Event Loop Phases
Six phases executed in order on every iteration, plus two priority queues that drain between every phase
Phase 1
Timers
Executes callbacks whose minimum delay has elapsed, registered via setTimeout() or setInterval(). The delay is a threshold, not a guarantee — OS scheduling or a busy Poll phase can push it later.
setTimeout(fn, 0) · setInterval(fn, n)
Phase 2
Pending Callbacks
Runs I/O error callbacks deferred from the previous iteration — the most common case is TCP-level errors such as ECONNREFUSED. Rarely visible in application code.
TCP ECONNREFUSED · deferred I/O errors
Phase 3
Idle / Prepare
Used internally by Node.js to prepare state before the upcoming Poll phase. Not reachable from JavaScript — no userland API schedules work here. It exists purely for Node.js bookkeeping.
— internal use only —
Phase 4
Poll
The most critical phase. Retrieves new I/O events from the OS and executes their callbacks — incoming HTTP requests, DB results, file reads, network responses. When the queue is empty and no timers are pending, the loop blocks here waiting for new events.
HTTP requests · DB callbacks · fs · fetch network events
Phase 5
Check
Runs all setImmediate() callbacks. Executes immediately after Poll within the same cycle, making it reliably earlier than setTimeout(fn, 0) when called from inside an I/O callback.
setImmediate(fn)
Phase 6
Close Callbacks
Handles close events for sockets and handles destroyed abruptly. Runs last so all pending I/O for that resource has already been processed before cleanup code runs.
socket.on('close') · stream.destroy()
POST /request - A phase-by-phase walkthrough
Four operations — four different event loop destinations
server.js
import http from 'node:http';
import { writeFile } from 'node:fs/promises';
import db from './db.js';

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/request') {

    // ① Synchronous — executes immediately
    console.log('start');

    // ② Schedules for the Check phase
    setImmediate(() => {
      console.log('setImmediate');
    });

    // ③ Async DB query (callback style to continue to step 4)
    db.query('SELECT * FROM table', (err, rows) => {
      if (err) {
        res.writeHead(500);
        return res.end('Database Error');
      }
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(rows));
    });

    // ④ Timer phase
    setTimeout(async () => {
      const response = await fetch('https://api.example.com/data');
      const json     = await response.json();
      await writeFile('log.txt', JSON.stringify(json, null, 2));

      console.log('log.txt saved successfully');
    }, 0);

  }
});

server.listen(3000, () => {
  console.log('Server listening on http://localhost:3000');
});
How Node.js manages the Request
Each event loop cycle, all six phases — showing exactly when every callback fires
Cycle
1
Request arrives — synchronous code runs, async ops are registered
All four operations are initiated in this cycle. Sync code runs inline in Poll. setImmediate fires in Check. The DB query and setTimeout(0) are delegated — their callbacks land in future cycles.
Timers
No timers registered yet — queue empty
Pending
No deferred I/O errors — queue empty
Idle/Prep
Internal Node.js use only
Poll ★
POST /request arrives — OS delivers the incoming HTTP request; Node.js fires the handler callback
① console.log('start') — runs synchronously inline, no scheduling needed · printed: "start"
② setImmediate(fn) — callback pushed into the Check queue · will fire right after this Poll phase ends, in the same cycle
③ db.query('SELECT * FROM table', cb)delegated to libuv thread pool · JS thread is free instantly · callback enqueued when DB responds · will send HTTP 200 (or 500 on error)
④ setTimeout(asyncFn, 0)timer registered · 0 ms threshold → eligible for Timers phase on the next iteration · async function uses await for fetch, JSON parse, and writeFile (all Promise-based) · handler returns
Check ★
② setImmediate callback firesconsole.log('setImmediate') printed · same cycle as the request, right after Poll · fires before the setTimeout(0) which belongs to the next cycle's Timers phase
Close
No close events — queue empty
Cycle
2
setTimeout(0) fires in Timers · DB result arrives in Poll
Timers runs first: the 0 ms delay has elapsed, so the callback executes and initiates fetch(). Poll runs next and handles the DB result — the HTTP response is sent to the client.
Timers ★
④ setTimeout callback fires — 0 ms threshold elapsed · asyncFn() begins executing
fetch('https://api.example.com/data') called — async network I/O initiated via OS · returns a pending Promise · execution suspends at await · callback returns control to the loop
Pending
Queue empty
Idle/Prep
Internal Node.js use only
Poll ★
③ DB query result arrives — libuv signals completion · db.query callback runs · on error: res.writeHead(500) · on success: res.writeHead(200) + res.end(JSON.stringify(rows)) · HTTP response sent to client ✓
Check
setImmediate queue empty
Close
Queue empty
⟳   Cycles 3 … N — loop continues, waiting for the fetch network response
The loop blocks in Poll, waiting for the OS to signal that api.example.com has responded. Other incoming requests are processed normally during this time.
Cycle
N
fetch() resolves — microtask chain runs — await writeFile() initiated
The OS signals that the HTTP response from the external API has arrived. The Poll I/O event resolves the fetch Promise, draining two microtasks before the next Poll callback gets a turn.
Timers
No pending timers
Pending
Queue empty
Idle/Prep
Internal Node.js use only
Poll ★
Network I/O event fires — OS signals that the fetch HTTP response is ready · the underlying I/O callback runs, resolving the fetch Promise
↓   microtask queue — drained completely before the next Poll event   ↓
μ   await fetch() continuation — response object now available · response.json() called → returns a new pending Promise · suspends at next await
μ   await response.json() continuation — JSON parsed · json data available · writeFile('log.txt', JSON.stringify(json, null, 2)) called from node:fs/promisesPromise-based I/O delegated to libuv · async function suspends at await writeFile()
Check
Queue empty
Close
Queue empty
Cycle
N+1
writeFile Promise resolves — log.txt saved, console.log fires via microtask
libuv signals that the file write finished. Because writeFile is from node:fs/promises, completion resolves its Promise. The await writeFile() continuation runs as a microtask — console.log('log.txt saved successfully') executes before the next I/O callback. The full lifecycle of the POST /request is now complete.
Timers
No pending timers
Pending
Queue empty
Idle/Prep
Internal Node.js use only
Poll ★
④ writeFile I/O event fires — libuv signals the write is done · fs/promises Promise resolves · await writeFile() continuation queued as a microtask
↓   microtask queue — drained before the next Poll event   ↓
μ   await writeFile() continuation — write confirmed · console.log('log.txt saved successfully') printed · full operation complete ✓
Check
Queue empty
Close
Queue empty
↺ loop continues…
Full execution order — timeline
Cycle 1
Poll
① console.log
"start"
Poll
③ db.query
→ libuv ↗
Poll
④ setTimeout
registered
Check
② setImmediate
"setImmediate"
Cycle 2
Timers
④ setTimeout
fires → fetch ↗
Poll
③ db.query cb
→ res.end() ✓
Cycle N
Poll
fetch response
arrives
microtasks — fully drained
μ
await fetch
→ response.json()
μ
await .json()
→ await writeFile ↗
Cycle N+1
Poll
writeFile
Promise resolves
microtask
μ
await writeFile
log.txt saved ✓
Key insights
setImmediate vs setTimeout(0) — deterministic inside I/O. Outside an I/O callback the order is unpredictable (depends on OS timing). Inside one (like our request handler) setImmediate is always first — it targets Check in the current cycle, while setTimeout(0) must wait for the Timers phase of the next cycle.
The JS thread never waits for the DB. db.query() is handed to libuv instantly. The handler registers the timer, queues setImmediate, and returns — all without waiting. The DB result, the setTimeout callback, and the setImmediate callback are all in flight concurrently, each racing to their respective phase.
fetch() resolves through microtasks, not a new Poll event. When the network response arrives, the underlying I/O callback fires in Poll and resolves the Promise. The await continuation is then a microtask — it runs inside the current microtask drain window, before the next I/O callback, not in a separate loop cycle.
await writeFile() resolves as a microtask, not a new Poll callback. Because writeFile comes from node:fs/promises, it returns a Promise. When libuv finishes the disk write, the Poll I/O event resolves that Promise. The await continuation — including console.log('log.txt saved successfully') — runs as a microtask in the same drain window, before any other I/O callback. With the legacy fs.writeFile(cb) the completion would arrive as a separate Poll event in its own loop cycle.
Microtask queue drains completely between I/O callbacks. After the fetch Poll event, all pending microtasks run before the next Poll callback gets a turn. await fetch() and await response.json() drain in Cycle N. After writeFile's I/O event fires in Cycle N+1, await writeFile() drains as its own microtask — each step uninterrupted by any other I/O callback.
Why does console.log('log.txt saved successfully') fire as a microtask? writeFile is imported from node:fs/promises — it returns a Promise, not a callback. When libuv completes the disk write, the Poll I/O event fires and resolves the Promise. The await writeFile() continuation is then a Promise microtask — it runs inside the microtask drain window triggered by that Poll event, before any other I/O callback can execute. This is in contrast to the legacy fs.writeFile(fn, cb) API, where the completion callback would be a separate Poll event in its own loop iteration.