Understanding Node.js architecture is crucial for building scalable applications. Let’s dive into the event loop, async operations, and how Node.js handles concurrency.
The Event Loop
Node.js uses a single-threaded event loop to handle asynchronous operations efficiently.
Event Loop Phases
// Simplified event loop phases
┌───────────────────────────┐
│ ┌─────────────────────┐ │
│ │ Timers │ │ // setTimeout, setInterval
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Pending Callbacks │ │ // I/O callbacks
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Idle, Prepare │ │ // Internal use
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Poll │ │ // Fetch new I/O events
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Check │ │ // setImmediate callbacks
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Close Callbacks │ │ // socket.on('close')
│ └─────────────────────┘ │
└───────────────────────────┘
How It Works
// Example: Understanding execution order
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
// Why:
// - Synchronous code runs first (1, 4)
// - Microtasks (Promises) run before macrotasks (setTimeout)
// - Event loop processes callbacks
Asynchronous Operations
Callbacks
// Traditional callback
fs.readFile('file.txt', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
Promises
// Promise-based
fs.promises.readFile('file.txt')
.then(data => console.log(data))
.catch(err => console.error(err));
// Async/await
async function readFile() {
try {
const data = await fs.promises.readFile('file.txt');
console.log(data);
} catch (err) {
console.error(err);
}
}
Event Emitters
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter();
myEmitter.on('event', (data) => {
console.log('Event received:', data);
});
myEmitter.emit('event', 'Hello World');
Concurrency Model
Single-Threaded but Non-Blocking
// Node.js handles I/O asynchronously
const http = require('http');
// This doesn't block
http.get('http://example.com', (res) => {
res.on('data', (chunk) => {
console.log(chunk);
});
});
// This continues immediately
console.log('Request sent, continuing...');
Worker Threads for CPU-Intensive Tasks
// For CPU-intensive operations
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.postMessage('Start calculation');
worker.on('message', (result) => {
console.log('Result:', result);
});
} else {
parentPort.on('message', (msg) => {
// Heavy computation
const result = performHeavyCalculation();
parentPort.postMessage(result);
});
}
Scaling Strategies
Cluster Module
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
const numWorkers = os.cpus().length;
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.id} died`);
cluster.fork(); // Restart worker
});
} else {
// Worker process
require('./server.js');
}
Load Balancing
// PM2 for process management
// pm2 start app.js -i max
// Or use nginx/HAProxy for load balancing
Best Practices
1. Avoid Blocking the Event Loop
// Bad: Blocks event loop
function heavyComputation() {
let result = 0;
for (let i = 0; i < 10000000000; i++) {
result += i;
}
return result;
}
// Good: Use worker threads or break into chunks
function asyncHeavyComputation() {
return new Promise((resolve) => {
setImmediate(() => {
// Process in chunks
resolve(computeChunk());
});
});
}
2. Use Streams for Large Data
// Bad: Loads entire file into memory
const data = fs.readFileSync('large-file.txt');
// Good: Stream processing
const stream = fs.createReadStream('large-file.txt');
stream.on('data', (chunk) => {
processChunk(chunk);
});
3. Handle Errors Properly
// Always handle errors in async operations
async function fetchData() {
try {
const data = await fetch('http://api.example.com');
return data;
} catch (error) {
// Log and handle error
console.error('Fetch failed:', error);
throw error;
}
}
Performance Tips
1. Connection Pooling
// Database connection pooling
const pool = mysql.createPool({
connectionLimit: 10,
host: 'localhost',
user: 'user',
password: 'password',
database: 'mydb'
});
2. Caching
// Use Redis for caching
const redis = require('redis');
const client = redis.createClient();
async function getCachedData(key) {
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const data = await fetchData();
await client.setex(key, 3600, JSON.stringify(data));
return data;
}
3. Compression
// Enable gzip compression
const compression = require('compression');
app.use(compression());
Common Pitfalls
1. Callback Hell
// Bad: Nested callbacks
fs.readFile('file1.txt', (err, data1) => {
fs.readFile('file2.txt', (err, data2) => {
fs.readFile('file3.txt', (err, data3) => {
// Deep nesting
});
});
});
// Good: Use async/await
async function readFiles() {
const [data1, data2, data3] = await Promise.all([
fs.promises.readFile('file1.txt'),
fs.promises.readFile('file2.txt'),
fs.promises.readFile('file3.txt')
]);
}
2. Unhandled Promise Rejections
// Always handle promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// Log and handle
});
Conclusion
Node.js architecture is powerful because:
- Event-driven: Handles many connections efficiently
- Non-blocking I/O: Doesn’t wait for operations
- Single-threaded: Simpler concurrency model
- Scalable: Can handle thousands of connections
Key takeaways:
- Understand the event loop phases
- Use async/await for cleaner code
- Avoid blocking operations
- Use worker threads for CPU-intensive tasks
- Implement proper error handling
- Scale with clustering or load balancing
Master these concepts to build high-performance Node.js applications! 🚀