Junior 3 min · March 06, 2026

Node.js Event Loop — The Sync Crypto Gotcha

When a single sync crypto call pushed event loop lag beyond 2000ms, the API died.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Node.js runs JavaScript outside the browser using Chrome's V8 engine
  • Single-threaded event loop with non-blocking I/O — handles thousands of concurrent connections without thread-per-request overhead
  • Libuv manages async I/O (files, network, DNS) via an OS-level thread pool
  • npm is the default package manager — over 2 million packages, but dependency bloat is a real production risk
  • Biggest mistake: blocking the event loop with CPU-heavy work - use worker_threads or offload to a dedicated service

How Node.js Handles Concurrency

Traditional servers, like Apache, create one thread per connection. This consumes significant memory as the number of users grows. Node.js takes a different approach: it uses a single main thread and an Event Loop. When an I/O operation (like a database query or file read) is initiated, Node hands the task off to the system kernel or a background thread pool (Libuv). The main thread remains free to handle new incoming requests immediately.

This 'non-blocking' nature is why a single Node.js instance can out-perform traditional multi-threaded servers in I/O-bound scenarios.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 
 * Package: io.thecodeforge.node.basics
 */
const fs = require('fs');

console.log('1. Initiating non-blocking file read...');

// fs.readFile is asynchronous and non-blocking
fs.readFile('large-report.pdf', (err, data) => {
    if (err) {
        console.error('Error reading file:', err.message);
        return;
    }
    // This runs only when the OS finishes the heavy lifting
    console.log(`3. Success! Processed ${data.length} bytes.`);
});

// This executes while the file is still being read by the OS
console.log('2. Main thread is free! Handling other user requests...');
Output
1. Initiating non-blocking file read...
2. Main thread is free! Handling other user requests...
3. Success! Processed 45210 characters.
Production Insight
A single synchronous file read of 500MB blocks the event loop for ~200ms.
During that time, all other requests queue up — latency spikes follow.
Rule: never use fs.readFileSync in a request handler; use the async variant.
Key Takeaway
Non-blocking I/O is the superpower.
Block the event loop and you lose all concurrency.
Use async APIs for I/O, worker_threads for CPU work.
Choose the I/O pattern
IfOperation involves disk, network, or database
UseUse callbacks, promises, or async/await with non-blocking APIs
IfOperation is CPU-bound (image resizing, crypto, JSON parse of huge data)
UseOffload to worker_threads or a separate microservice
IfNeed to wait on multiple independent I/O operations
UseUse Promise.all() to parallelise — don't serialise

Building a Production-Ready HTTP Server

While frameworks like Express are the industry standard, understanding the native http module is essential for grasping how Node.js communicates with the outside world. Every request is a stream, and every response is a stream.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 
 * Package: io.thecodeforge.node.web
 */
const http = require('http');

const PORT = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
    const { method, url } = req;

    // Standard REST routing logic
    if (method === 'GET' && url === '/') {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ 
            status: 'success', 
            message: 'Welcome to TheCodeForge API' 
        }));

    } else if (method === 'GET' && url === '/health') {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('UP');

    } else {
        res.writeHead(404, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Endpoint Not Found' }));
    }
});

server.listen(PORT, () => {
    console.log(`[TheCodeForge] Server ignited on port ${PORT}`);
});
Output
[TheCodeForge] Server ignited on port 3000
Streaming requests
req is a ReadableStream. If you don't read the body, backpressure builds and the client hangs. Always consume or pipe the request body even if you don't need it.
Production Insight
In production, never write raw routing logic inside createServer.
Missing a content-type header leads to silent JSON parse failures on clients.
Rule: abstract routing into a framework (Express, Fastify) or at least separate handler functions.
Key Takeaway
Understand streams, but don't write raw servers in production.
Use frameworks for routing, middleware, and error handling.
Know the http module internals to debug connection issues.

Modules — CommonJS and ES Modules

Node.js originally popularized CommonJS (require), but the industry has moved toward the official JavaScript standard: ES Modules (import/export). Choosing the right one impacts how your code is bundled and optimized.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 
 * Package: io.thecodeforge.node.modules
 */

// --- CommonJS (Standard in older Node apps) ---
// utils.js -> module.exports = { log: (msg) => console.log(msg) };
// app.js   -> const { log } = require('./utils');

// --- ES Modules (Recommended for new projects) ---
// In package.json, set "type": "module"

import os from 'os';
import { networkInterfaces } from 'os';

const systemMetrics = {
    platform: os.platform(),
    freeMem: (os.freemem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
    uptime: (os.uptime() / 3600).toFixed(1) + ' hours'
};

console.log('System Status:', systemMetrics);
Output
System Status: { platform: 'linux', freeMem: '4.21 GB', uptime: '12.4 hours' }
Production Insight
Mixing CommonJS and ESM in the same project causes ERR_REQUIRE_ESM.
Use .mjs for ESM files or set "type": "module" in package.json.
Rule: pick one system per project — never mix unless you understand the transpilation chain.
Key Takeaway
ES Modules are the standard.
CommonJS still works but is legacy.
Set "type": "module" and use import/export for new projects.
Choose your module system
IfNew project with modern Node (16+)
UseUse ES Modules — set 'type': 'module' in package.json
IfExisting project with many CommonJS dependencies
UseStay with CommonJS, or use dynamic import() for specific ESM packages
IfBuilding a library shared with browser/Node
UseWrite in ESM and let bundlers handle CommonJS fallback

npm and Dependency Management

npm is Node's default package manager. It installs dependencies into node_modules and tracks them in package.json. The package-lock.json locks exact versions to ensure reproducible builds across environments.

ExampleBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Initialize a new project
npm init -y

# Install a package as a production dependency
npm install express

# Install as dev dependency
npm install --save-dev nodemon

# Run a script defined in package.json
npm run start

# List outdated packages
npm outdated

# Update all packages (respects semver)
npm update
Lockfile is critical
Without package-lock.json, different environments may get different versions of transitive dependencies. This causes 'works on my machine' bugs. Always commit the lockfile.
Production Insight
A node_modules folder can exceed 300MB for a simple app — don't commit it.
Use npm ci in CI/CD for deterministic installs from lockfile.
Rule: never run npm update blindly in production; review breaking changes first.
Key Takeaway
Lockfile is your contract for reproducible builds.
Use npm ci in CI, npm install locally.
Audit dependencies regularly with npm audit.

The Event Loop Deep Dive — Phases and Timers

The Event Loop is the core of Node.js concurrency. It runs in phases: timers, pending callbacks, idle/prepare, poll, check, close. Understanding this order is essential for debugging async behaviour and unexpected delays.

ExampleJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 
 * Package: io.thecodeforge.node.eventloop
 */
const fs = require('fs');
const crypto = require('crypto');

console.log('1. Main script start');

setTimeout(() => console.log('5. Timer phase'), 0);

setImmediate(() => console.log('6. Check phase (setImmediate)'));

process.nextTick(() => console.log('3. nextTick queue'));

Promise.resolve().then(() => console.log('4. Microtask queue (Promise)'));

fs.readFile('dummy.txt', () => {
    console.log('7. I/O callback (poll phase)');
    process.nextTick(() => console.log('8. nextTick inside I/O'));
    setImmediate(() => console.log('9. setImmediate inside I/O'));
});

console.log('2. Main script end');
Output
1. Main script start
2. Main script end
3. nextTick queue
4. Microtask queue (Promise)
5. Timer phase
6. Check phase (setImmediate)
7. I/O callback (poll phase)
8. nextTick inside I/O
9. setImmediate inside I/O
Production Insight
process.nextTick() can starve the event loop if called recursively.
Promise microtasks run after nextTick but before timers — ordering matters.
Rule: prefer setImmediate over nextTick for deferring work to the next iteration.
Key Takeaway
nextTick -> Promise -> Timer -> I/O -> setImmediate.
Don't starve the loop with synchronous microtask chains.
Use setImmediate when you want to yield control.
● Production incidentPOST-MORTEMseverity: high

The CPU-Bound Route That Killed Our API

Symptom
Under load, every request to the API started timing out after a few minutes. CPU usage was moderate (60%), but the event loop lag exceeded 2000ms.
Assumption
The team assumed that because the decryption was done with crypto, it must be asynchronous. They didn't check the method signature.
Root cause
A developer used crypto.pbkdf2Sync() instead of the async crypto.pbkdf2(). The synchronous version blocks the event loop for the duration of the hash computation.
Fix
Replaced all ...Sync calls with the async equivalents. Added a worker_threads pool for any remaining CPU-heavy operations. Implemented event loop lag monitoring with process.hrtime() and alerts if lag > 50ms.
Key lesson
  • Never use synchronous crypto or filesystem methods in a request handler.
  • Monitor event loop lag in production — it's the canary for blocking code.
  • Code reviews must flag Sync functions in request paths.
Production debug guideSymptom → Action guide for common production problems4 entries
Symptom · 01
All requests start timing out after a few minutes
Fix
Check event loop lag: use clinic doctor or manually log process.hrtime() delta in a setInterval. Look for Sync functions or CPU-heavy loops.
Symptom · 02
Memory usage grows linearly over time
Fix
Take heap snapshot with node --inspect and compare snapshots. Check for closures in Promises, unclosed connections, or large retained objects.
Symptom · 03
'Cannot find module' error after deployment
Fix
Verify node_modules contains the package. Run npm ls <package> to check dependency tree. If missing, ensure npm ci ran correctly and lockfile is up to date.
Symptom · 04
HTTP connections stay open indefinitely
Fix
Check for missing res.end() in response handlers. Use --inspect to list open handles. Add request timeout middleware (e.g., connect-timeout).
★ Quick Debug Cheat Sheet: Node.js Async IssuesInstant commands to diagnose the three most common Node.js production failures.
Event loop blocked
Immediate action
Measure lag
Commands
node -e "setInterval(() => { const start = Date.now(); setImmediate(() => console.log('lag (ms):', Date.now() - start)); }, 1000)"
clinic doctor -- node app.js
Fix now
Replace Sync calls with async; move CPU work to worker_threads
Memory leak+
Immediate action
Take heap snapshot
Commands
node --inspect app.js
chrome://inspect -> Memory tab -> Take snapshot before and after load test
Fix now
Fix closures, limit global caches, use WeakMap for event listeners
'port already in use'+
Immediate action
Kill process on port
Commands
lsof -ti :3000 | xargs kill
fuser -k 3000/tcp
Fix now
Use environment variable for port, or use server.close() on SIGTERM
Node.js vs Traditional Threaded Servers
AspectNode.jsApache (thread-per-connection)NGINX (event-driven)
Concurrency modelSingle thread + event loopThread per connectionEvent-driven (similar to Node.js)
Memory per connection~10-20 KB~ 2-8 MB~ 10-20 KB
Best forI/O-bound workloads (APIs, real-time)CPU-bound / simple static filesStatic files, reverse proxy
Worst forCPU-heavy tasks (image processing)High concurrency with many connectionsDynamic application logic

Key takeaways

1
Node.js runs JavaScript outside the browser using Google's V8 engine.
2
Single-threaded event loop with non-blocking I/O
efficient for many concurrent I/O operations.
3
CPU-intensive tasks block the event loop
use worker_threads for computation, not for waiting on I/O.
4
CommonJS (require/module.exports) is the traditional module system. ES Modules (import/export) are the modern standard.
5
npm is Node's package manager
package.json describes your project's dependencies and scripts.
6
Always commit package-lock.json and use npm ci for deterministic builds.
7
Monitor event loop lag in production
it's the earliest sign of blocking code.

Common mistakes to avoid

4 patterns
×

Blocking the event loop with sync I/O

Symptom
Event loop lag spikes, all requests slow down, timeouts increase.
Fix
Replace readFileSync, writeFileSync, pbkdf2Sync, etc., with their async counterparts.
×

Not handling promise rejections

Symptom
Unhandled promise rejection warnings, later crashes in Node 15+ (throwing by default).
Fix
Attach .catch() to every promise chain, or use a global process.on('unhandledRejection') handler.
×

Using `process.nextTick()` for deferred work

Symptom
Event loop starvation, stack overflow from recursive nextTick calls.
Fix
Use setImmediate() to defer work to the next iteration instead of nextTick.
×

Forgetting to commit `package-lock.json`

Symptom
Inconsistent builds across environments, 'works on my machine' bugs.
Fix
Add package-lock.json to version control. Use npm ci in CI/CD.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the phases of the Node.js Event Loop. Where does process.nextTic...
Q02SENIOR
Why is Node.js considered 'unsuitable' for heavy data crunching, and how...
Q03SENIOR
LeetCode Scenario: Given an array of 1,000 file paths, write a script to...
Q04SENIOR
Compare and contrast the behavior of 'require()' vs 'import' regarding s...
Q05JUNIOR
What is 'Callback Hell' and how do Promises or Async/Await resolve the u...
Q01 of 05SENIOR

Explain the phases of the Node.js Event Loop. Where does process.nextTick() fit into these phases?

ANSWER
The Event Loop has six phases: timers, pending callbacks, idle/prepare, poll, check, close. Between each phase, Node processes the nextTick queue and then the microtask queue (Promises). process.nextTick() runs after the current operation completes, before moving to the next phase. This makes it higher priority than setImmediate() which runs in the check phase.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is Node.js good for CPU-intensive tasks?
02
What is the difference between Node.js and a browser JavaScript environment?
03
What is Libuv and why does Node.js need it?
04
Should I use `npm install` or `npm ci` in CI/CD?
05
What is the difference between `setImmediate` and `process.nextTick`?
🔥

That's Node.js. Mark it forged?

3 min read · try the examples if you haven't

Previous
How I Generate 50+ shadcn Components Automatically with AI
1 / 18 · Node.js
Next
Node.js Modules and CommonJS