DEV Community

Alex Chen
Alex Chen

Posted on

Node.js Event Loop: Visual Guide to Async Programming (2026)

Node.js Event Loop: Visual Guide to Async Programming (2026)

The event loop is what makes Node.js fast. Understanding it means writing faster, bug-free async code.

The Big Picture

┌──────────────────────────────────┐
│                                  │
│   ┌─────┐  ┌──────────────────┐ │
│   │ Timers│  │    Check        │ │
│   │(set  │  │ (setImmediate)  │ │
│   │Timeout│  │                 │ │
│   └──┬──┘  └────────┬─────────┘ │
│      │               │           │
│      ▼               ▼           │
│   ┌──────────────────────────┐  │
│   │     Poll / I/O Callbacks  │  │ ← Where most of your code runs!
│   └────────────┬─────────────┘  │
│                │                │
│                ▼                │
│   ┌──────────────────────────┐  │
│   │     Close Callbacks       │  │
│   └──────────────────────────┘  │
│                                  │
└──────────────────────────────────┘

Key insight: JavaScript is SINGLE-THREADED.
But I/O (network, file system) happens on SEPARATE threads.
The event loop coordinates everything.
Enter fullscreen mode Exit fullscreen mode

How It Actually Works

// Each "tick" of the event loop does this:

// Phase 1: Timers (setTimeout, setInterval)
// → Check if any timer has expired. If yes, run its callback.

// Phase 2: Pending callbacks (I/O errors, etc.)
// → Run callbacks deferred from previous I/O operations.

// Phase 3: Poll (the main phase!)
// → Execute I/O callbacks (fs.readFile, http.request, etc.)
// → If no callbacks: wait for new I/O OR run setImmediate callbacks.
// → This is where your app spends most of its time.

// Phase 4: Check (setImmediate callbacks)
// → Run all setImmediate callbacks.

// Phase 5: Close callbacks
// → Run socket.on('close'), etc.

// Then... back to Phase 1! (loop!)
Enter fullscreen mode Exit fullscreen mode

Microtasks vs Macrotasks

// CRITICAL distinction that causes bugs:

console.log('1. Script start');

setTimeout(() => console.log('4. setTimeout (macrotask)'), 0);

Promise.resolve()
  .then(() => console.log('2. Promise.then (microtask)'))
  .then(() => console.log('3. Promise.then #2 (microtask)'));

setImmediate(() => console.log('5. setImmediate (macrotask)'));

process.nextTick(() => console.log('0. process.nextTick (microtask)'));

// Output order:
// 1. Script start
// 0. process.nextTick (microtask) ← HIGHEST priority microtask!
// 2. Promise.then (microtask)
// 3. Promise.then #2 (microtask)
// 4. setTimeout (macrotask)
// 5. setImmediate (macrotask)

// Priority order:
// 1. process.nextTick (same tick, after current operation completes)
// 2. Promises (.then/.catch/.finally) — microtask queue
// 3. Other macrotasks in order: timers → poll → check → close
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & Fixes

Mistake 1: Blocking the Event Loop

// ❌ Blocking: CPU-heavy operation freezes EVERYTHING
app.get('/compute', (req, res) => {
  const result = heavyComputation(data); // Blocks for 5 seconds!
  res.json({ result });
});
// During those 5 seconds: NO requests handled, NO timers fire, NO heartbeats!

// ✅ Fix 1: Offload to worker thread
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  app.get('/compute', (req, res) => {
    const worker = new Worker(__filename, {
      workerData: { data: req.body.data }
    });
    worker.on('message', (result) => {
      res.json({ result }); // Main thread stays responsive!
    });
  });
} else {
  const result = heavyComputation(workerData.data);
  parentPort.postMessage(result);
}

// ✅ Fix 2: Break into chunks with setImmediate
function processLargeArray(array, callback) {
  let index = 0;
  const chunkSize = 1000;

  function processChunk() {
    const end = Math.min(index + chunkSize, array.length);
    for (; index < end; index++) {
      processItem(array[index]); // Process one item
    }
    if (index < array.length) {
      setImmediate(processChunk); // Yield to event loop, then continue
    } else {
      callback(); // Done!
    }
  }
  processChunk();
}
// Event loop stays responsive between chunks!

// ✅ Fix 3: Use native async APIs (libuv handles threading)
const crypto = require('crypto');
// crypto.pbkdf2 runs on thread pool — doesn't block event loop!
crypto.pbkdf2(password, salt, iterations, keylen, (err, derivedKey) => {
  res.json({ key: derivedKey.toString('hex') });
});
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Unhandled Promise Rejections

// ❌ Silent failure
async function fetchData() {
  throw new Error('Network error'); // Unhandled rejection!
}
fetchData(); // No .catch() → warning in Node.js, crash in future versions!

// ✅ Always handle rejections (at minimum):
fetchData().catch(err => console.error('Fetch failed:', err));

// ✅ Or use global handler as safety net:
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // In production: log to error tracking service!
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // Don't just ignore! Log + graceful shutdown:
  cleanupAndExit(1);
});

// ✅ Best: Top-level await with try/catch in main file:
try {
  await startServer();
} catch (err) {
  console.error('Failed to start:', err);
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Callback Hell vs Async/Await Anti-Patterns

// ❌ Old school: Callback hell
fs.readFile('config.json', (err, data) => {
  if (err) throw err;
  const config = JSON.parse(data);
  db.connect(config.dbUrl, (err, client) => {
    if (err) throw err;
    client.query('SELECT * FROM users', (err, rows) => {
      if (err) throw err;
      res.json(rows); // Good luck reading this...
    });
  });
});

// ✅ Modern: Clean async/await
async function handleRequest(req, res) {
  try {
    const config = JSON.parse(await fs.promises.readFile('config.json'));
    const client = await db.connect(config.dbUrl);
    const rows = await client.query('SELECT * FROM users');
    res.json(rows);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal error' });
  }
}

// ⚠️ But avoid THIS anti-pattern:
// ❌ Fire-and-forget (unhandled promise!)
app.get('/data', async (req, res) => {
  const data = await fetchData(); // If this throws → unhandled rejection!
  res.json(data);
});

// ✅ Wrap route handlers properly:
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/data', asyncHandler(async (req, res) => {
  const data = await fetchData(); // Errors go to Express error middleware!
  res.json(data));
}));
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

Pattern 1: Request Coalescing

// Multiple requests for same data within window → single fetch
class RequestCache {
  constructor(ttl = 1000) {
    this.cache = new Map();
    this.ttl = ttl;
  }

  async get(key, fetcher) {
    const cached = this.cache.get(key);

    // Return cached if fresh
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      return cached.promise;
    }

    // If already fetching (in-flight), return same promise
    if (cached && !cached.resolved) {
      return cached.promise; // Deduplicates concurrent requests!
    }

    // New request
    const promise = fetcher().then(data => {
      this.cache.get(key).resolved = true;
      return data;
    });

    this.cache.set(key, { promise, timestamp: Date.now(), resolved: false });
    return promise;
  }
}

// Usage: 100 simultaneous requests for same user → 1 actual API call
const userCache = new RequestCache(5000); // 5s cache
app.get('/user/:id', async (req, res) => {
  const user = await userCache.get(`user:${req.params.id}`, () =>
    fetchUserFromAPI(req.params.id)
  );
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Rate Limiting with Queue

// Limit concurrent async operations (e.g., API calls, DB queries)
class ConcurrencyLimiter {
  constructor(maxConcurrent = 5) {
    this.maxConcurrent = maxConcurrent;
    this.running = 0;
    this.queue = [];
  }

  async execute(fn) {
    if (this.running >= this.maxConcurrent) {
      // Wait for slot to open
      await new Promise(resolve => this.queue.push(resolve));
    }
    this.running++;
    try {
      return await fn();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        this.queue.shift()(); // Let next waiting task proceed
      }
    }
  }
}

// Usage: Process 1000 files, but only 5 at a time
const limiter = new ConcurrencyLimiter(5);
await Promise.all(
  files.map(file => limiter.execute(() => processFile(file)))
);
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Timeout Wrapper

// Add timeout to ANY async operation
function withTimeout(promise, ms, message = 'Operation timed out') {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(message)), ms)
    )
  ]);
}

// Usage:
const data = await withTimeout(
  fetchFromSlowAPI(),
  3000,
  'API response took too long'
);

// Combined with retry:
async function fetchWithRetry(url, options = {}) {
  const { maxRetries = 3, timeout = 5000, delay = 1000 } = options;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await withTimeout(fetch(url), timeout);
    } catch (err) {
      if (attempt === maxRetries) throw err;
      console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay * attempt)); // Exponential backoff
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Debugging Async Code

// Enable async stack traces (Node.js 16+)
// --async-stack-traces flag or via code:
Error.stackTraceLimit = Infinity; // Show full stack traces

// Track active promises (debugging hanging operations):
const activePromises = new Set();

function trackableFetch(url) {
  const promise = fetch(url).finally(() => activePromises.delete(promise));
  activePromises.add(promise);

  // Auto-warn if promise takes too long
  setTimeout(() => {
    if (activePromises.has(promise)) {
      console.warn(`⚠️ Pending promise for ${url} (>10s)`);
    }
  }, 10000);

  return promise;
}

// Async hooks (advanced debugging):
import async_hooks from 'async_hooks';

const hook = async_hooks.createHook({
  init(asyncId, type, triggerAsyncId) {
    if (type === 'PROMISE') {
      // Track every promise creation
    }
  },
  destroy(asyncId) {
    // Track cleanup
  },
});
hook.enable();

// Practical: Measure async operation duration
function measureTime(label) {
  const start = process.hrtime.bigint();
  return {
    end(result) {
      const duration = Number(process.hrtime.bigint() - start) / 1e6;
      console.log(`${label}: ${duration.toFixed(2)}ms`);
      return result;
    },
  };
}

// Usage:
const timer = measureTime('DB query');
const rows = await db.query('SELECT * FROM users');
timer.end(rows);
Enter fullscreen mode Exit fullscreen mode

What's the trickiest async bug you've ever debugged?

Follow @armorbreak for more practical developer guides.

Top comments (0)