Skip to content

node_08 - The Event Loop

Your async code is broken because you don't understand the event loop let's fix that
Node runs JS on a single thread - one call stack , one thing at a time. But I/O operations get handed off to the system (libuv) and callbacks queue up for when the stack clears. That queue has phases: timers , I/O callbacks , idle/prepare , poll , check , close. setTimeout vs setImmediate vs process.nextTick each land in different phases. Guess wrong and your "sequential" operations execute in the wrong order

the six phases

Each iteration of the event loop (called a "tick") goes through six phases in order. Callbacks queue up in each phase and drain before moving to the next. To understand async order in Node , you need to know which phase runs what

  1. timers - setTimeout and setInterval callbacks fire here if their delay has elapsed. "Elapsed" doesn't mean "exact time" - it means "at least this much time has passed." If the poll phase blocks , timers wait
  2. pending callbacks - I/O callbacks that were deferred to the next iteration. Think TCP error callbacks , EPIPE from a broken socket. These don't fit cleanly anywhere else
  3. idle , prepare - internal libuv bookkeeping. You don't interact with this
  4. poll - the main event. Retrieves new I/O events , executes their callbacks. If nothing is pending , Node waits here for new connections or data. This phase decides when to move to check if setImmediate is queued
  5. check - setImmediate callbacks run here. This phase exists specifically so setImmediate runs after poll completes , before the next timer cycle
  6. close callbacks - cleanup events like socket.on('close') or stream.destroy(). Last stop before the loop may exit
const fs = require('fs')

// Demonstrate phase execution order
fs.readFile(__filename , () => {
  console.log('1 - I/O callback (poll phase)')

  setTimeout(() => console.log('2 - timer callback'))
  setImmediate(() => console.log('3 - check phase immediate'))
  process.nextTick(() => console.log('4 - nextTick (microtask)'))
})

// Typical output when I/O just completed:
// 1 - I/O callback (poll phase)
// 4 - nextTick (microtask)     <- runs between every phase
// 3 - check phase immediate     <- check phase runs first
// 2 - timer callback            <- next timer phase

// Notice process.nextTick ran BEFORE setImmediate
// Microtasks always drain between phases

The gotcha: Outside any I/O callback (like in the top-level module scope) , the order between setTimeout(cb, 0) and setImmediate(cb) is non-deterministic. It depends on how long the poll phase takes to initialize. Inside an I/O callback , setImmediate always wins because the check phase comes before the next timer phase

microtasks and macrotasks

This distinction is where most people lose the plot. The event loop processes macrotasks per phase. But microtasks (Promise callbacks , process.nextTick , queueMicrotask) run between every phase - they drain completely before the next phase starts

The full priority order within a single phase transition: 1. process.nextTick queue drains first (highest priority) 2. Promise microtask queue drains next (Promise.then , queueMicrotask) 3. Next event loop phase begins (macrotasks)

console.log('0 - synchronous start')

setTimeout(() => console.log('1 - setTimeout (macrotask)'), 0)
setImmediate(() => console.log('2 - setImmediate (macrotask)'))

Promise.resolve().then(() => console.log('3 - Promise.then (microtask)'))

process.nextTick(() => console.log('4 - process.nextTick (microtask)'))

queueMicrotask(() => console.log('5 - queueMicrotask (microtask)'))

console.log('6 - synchronous end')

// Output (deterministic):
// 0 - synchronous start
// 6 - synchronous end
// 4 - process.nextTick          <- microtask, highest priority
// 3 - Promise.then              <- microtask, second priority
// 5 - queueMicrotask            <- microtask, same as Promise
// 1 - setTimeout                <- timer phase macrotask
// 2 - setImmediate              <- check phase macrotask

// The microtask queue drains COMPLETELY before the first
// macrotask fires. All three microtasks execute before
// any timer callback or setImmediate

Security angle: If an attacker can inject microtasks faster than the event loop can drain them (Promise bombs , recursive process.nextTick chains) , they can starve I/O processing. The poll phase never gets to process new connections because microtasks keep filling the queue. This is a denial-of-service vector - rate-limit Promise creation in hot paths

process.nextTick vs setImmediate

The naming is historical and it's bad. process.nextTick fires before the next event loop phase - it's not actually part of the event loop at all. setImmediate fires in the check phase , which is at least one full iteration away. Despite the names , process.nextTick runs first and setImmediate runs... later than immediately

// process.nextTick runs before the next phase
// setImmediate runs in the check phase (after poll)
// Despite the name, setImmediate is NOT immediate

function deferWork() {
  // BAD: process.nextTick for I/O deferral
  // This delays I/O processing across ALL phases
  process.nextTick(() => {
    console.log('nextTick - runs before next phase')
  })

  // GOOD: setImmediate for deferring work
  // This only waits until the check phase
  setImmediate(() => {
    console.log('setImmediate - runs in check phase')
  })

  // The Node.js docs say: use setImmediate
  // unless you NEED to fire before the next phase
  // 99% of the time you want setImmediate
}

// The iconic pattern - why setImmediate wins inside I/O
const fs = require('fs')
fs.readFile(__filename , () => {
  setTimeout(() => console.log('timeout'))
  setImmediate(() => console.log('immediate'))
})
// Output:
// immediate   <- check phase runs right after poll
// timeout     <- timer phase runs on next iteration

// NextTick recursion - the infinite loop footgun
function badIdea() {
  process.nextTick(badIdea)
  // This never gives the event loop a chance to process I/O
  // setTimeout callbacks never fire
  // New HTTP connections never get accepted
  // You've DOS'd your own process
}

Rule: Use process.nextTick only when you need to fire a callback before the next phase - like emitting an event synchronously after a constructor runs (some legacy Node.js patterns). For everything else , use setImmediate. The Node docs recommend this. The Node core team recommends this. Stop using nextTick for deferral

blocking the event loop

Single-threaded means one slow route handler crashes the whole party. When JavaScript is executing synchronously (parsing JSON , running crypto , iterating a million-item array) , no I/O happens. No new requests are accepted. No responses are sent. The event loop is frozen

const express = require('express')
const crypto = require('crypto')
const app = express()

// DANGER: this blocks the entire process
app.post('/hash' , (req , res) => {
  // Synchronous crypto - blocks the event loop
  const hash = crypto.createHash('sha256')
    .update(req.body.password)
    .digest('hex')

  // While this runs: ZERO other requests get processed
  // Every client gets timeout errors
  // Your monitoring alerts start screaming
  res.json({ hash })
})

// SAFE: offload to async version (uses libuv thread pool)
app.post('/hash-safe' , (req , res) => {
  crypto.pbkdf2(req.body.password , 'salt' , 100000 , 64 , 'sha512' , (err , key) => {
    if (err) return res.status(500).json({ error: 'hashing failed' })
    res.json({ hash: key.toString('hex') })
  })
  // Thread pool handles the CPU work
  // Event loop keeps processing other requests
})

// DANGER: arbitrary JSON parsing from client
app.post('/parse' , (req , res) => {
  const data = JSON.parse(req.body)
  // What if req.body is 500MB of nested objects?
  // JSON.parse is synchronous - blocks until done
  // OOM crash or event loop starvation
  res.json(data)
})

Security angle - algorithmic complexity attacks: Attackers craft inputs that trigger worst-case performance in your synchronous operations. ReDoS attacks send strings that take polynomial time to match against your regex. Hash collision attacks send millions of keys that all hash to the same bucket , turning O(1) lookups into O(n). JSON bomb attacks send deeply nested [[[[...]]]] structures that consume all memory during parse

// REDOS example - catastrophic backtracking
const evilRegex = /^(a+)+b$/
const attack = 'a'.repeat(30)        // harmless
const weaponized = 'a'.repeat(100) + 'x'  // CPU pinned for seconds

// JSON bomb - deeply nested empty arrays
const jsonBomb = '['.repeat(100000) + ']'.repeat(100000)
// JSON.parse(this) = OOM crash or event loop frozen for minutes

// Protection: limit input sizes and validate schemas
function safeParse(body , maxDepth = 20) {
  let depth = 0
  return JSON.parse(body , (key , value) => {
    if (typeof value === 'object' && value !== null) {
      depth++
      if (depth > maxDepth) {
        throw new Error('JSON too deep - aborting')
      }
    }
    return value
  })
}

For actual CPU-heavy work (hash cracking , image processing , video transcoding) , don't even pretend the event loop can handle it. Use worker_threads to spawn a separate thread with its own event loop , its own V8 instance , and its own memory. Communicate via message passing

const { Worker } = require('worker_threads')

function runInWorker(workerData) {
  return new Promise((resolve , reject) => {
    const worker = new Worker('./heavy-worker.js' , { workerData })

    worker.on('message' , resolve)
    worker.on('error' , reject)
    worker.on('exit' , (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`))
    })
  })
}

// Express endpoint that doesn't freeze the server
app.post('/crack' , async (req , res) => {
  try {
    const result = await runInWorker({ hash: req.body.hash })
    res.json(result)
  } catch (err) {
    res.status(500).json({ error: 'worker failed' })
  }
  // Event loop remains responsive throughout
})

monitoring event loop lag

Detect event loop problems before they hit production. Libraries like process.hrtime.bigint() give you a metric that should stay under 50ms on a healthy server

let lastCheck = process.hrtime.bigint()

setInterval(() => {
  const now = process.hrtime.bigint()
  const lag = Number(now - lastCheck) / 1e6 // ms
  lastCheck = now

  if (lag > 100) {
    console.error(`Event loop lag: ${lag.toFixed(2)}ms - investigate immediately`)
    // Alert PagerDuty, increment a Prometheus counter
  }
}, 1000)

If your event loop lag consistently exceeds 200ms , you're either doing CPU work on the main thread or your I/O operations are blocking. Profile with --inspect and Chrome DevTools flame charts. The flame chart doesn't lie

prerequisites

node_07 - Node Architecture


next -> (continues to async section)