The JavaScript Event Loop: Call Stack, Task Queue & Microtasks
A production-focused deep dive into how the JavaScript event loop actually works — call stack mechanics, microtask vs. macrotask priority, connection pooling, atomic rate limiting, parallel async patterns, and graceful shutdown. Every concept is backed by enterprise-ready code you can use directly.
JavaScript runs on a single thread. That thread has one call stack, one memory heap, and one thing it can execute at any given moment. Yet a properly written Node.js server handles tens of thousands of concurrent connections without spawning a thread per request, without blocking, and without the kind of concurrency bugs that plague multi-threaded systems in Java or C++.
The mechanism behind that is the event loop. Every setTimeout, every await, every .then(), every fs.readFile callback — all of it runs because the event loop coordinates when JavaScript code actually executes. If you do not understand the event loop at a mechanical level, you will write bugs that only appear under production load, bugs that are nearly impossible to reproduce locally, and bugs where your logging shows no obvious error but requests hang or return stale data.
This article walks through the event loop from first principles to production-grade async patterns. Every code sample is the kind you would write in a real backend service, not a toy example.
The Call Stack
When JavaScript calls a function, the runtime pushes an execution context onto the call stack. That context holds the function's local variables, its arguments, and a return address pointing back to the calling code. When the function returns, its context pops off the stack. The stack is LIFO — last in, first out.
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { URL } from 'url';
function extractPathSegments(rawUrl: string, base: string): string[] {
const url = new URL(rawUrl, base);
return url.pathname.split('/').filter(Boolean);
}
function buildRouteKey(method: string, segments: string[]): string {
const normalized = segments.map((s) => (/^\d+$/.test(s) ? ':id' : s));
return `${method.toUpperCase()} /${normalized.join('/')}`;
}
function resolveRoute(req: IncomingMessage): string {
const segments = extractPathSegments(req.url ?? '/', 'http://localhost');
return buildRouteKey(req.method ?? 'GET', segments);
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const routeKey = resolveRoute(req);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(routeKey);
});
server.listen(3000);
When a request arrives and resolveRoute is called, the call stack builds up in layers: the createServer callback at the bottom, then resolveRoute, then extractPathSegments, then the URL constructor deep inside. The stack unwinds in reverse as each function returns. At no point does JavaScript execute two of these functions simultaneously. buildRouteKey does not start until extractPathSegments returns. resolveRoute does not return until buildRouteKey returns.
This sequential, deterministic execution within synchronous code is the foundation everything else builds on. The call stack is not concurrent. It is a strict execution order enforced by the runtime.
The stack has a hard size limit determined by the engine and the available memory. Unbounded recursion exhausts it. In V8 (the engine used by Node.js and Chrome), the limit is typically around 10,000–15,000 frames depending on frame size. Hitting that limit throws a RangeError: Maximum call stack size exceeded and crashes the current execution context.
Why Blocking the Stack Destroys Server Performance
Because JavaScript has one thread and one call stack, any synchronous operation that takes time blocks every other operation from running. There is no scheduler. There is no OS-level preemption. The event loop cannot move to the next task until the call stack is empty.
import { readFileSync, readdirSync } from 'fs';
import { createServer, IncomingMessage, ServerResponse } from 'http';
interface ConfigFile {
name: string;
content: Record<string, unknown>;
}
function loadAllConfigs(configDir: string): ConfigFile[] {
const files = readdirSync(configDir);
return files
.filter((f) => f.endsWith('.json'))
.map((file) => {
const raw = readFileSync(`${configDir}/${file}`, 'utf8');
return {
name: file,
content: JSON.parse(raw) as Record<string, unknown>,
};
});
}
const server = createServer((_req: IncomingMessage, res: ServerResponse) => {
const configs = loadAllConfigs('./config');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(configs));
});
server.listen(3000);
readdirSync and readFileSync are synchronous filesystem calls. They tell the OS to read from disk, then block the JavaScript thread until the OS returns the data. During that blocking period — which might be 1ms on a warm OS cache or 50ms on a cold HDD — every other incoming HTTP connection sits waiting. No other request handler runs. No timer callbacks fire. No database responses get processed. The single thread is occupied waiting for the filesystem.
A server handling 500 concurrent requests where each request blocks for 10ms on config reads is, in practice, a server that can only serve 2 requests per second despite having idle CPU cores. The CPU is not the bottleneck. The single blocked thread is.
This is why Node.js exposes async versions of every I/O operation and why you should never call the sync variants inside request handlers. readFileSync is appropriate in build scripts and CLI tools that run once. It has no place in a server.
The Event Loop's Role: Web APIs and the Task Queue
JavaScript itself does not perform I/O. The runtime environment — Node.js, the browser — provides APIs that delegate I/O to the operating system using non-blocking system calls (epoll on Linux, kqueue on macOS, IOCP on Windows). libuv, the library under Node.js, abstracts these into a unified async I/O model.
When you call fs.readFile, setTimeout, fetch, or net.connect, you hand work off to the runtime. Node.j pushes the call onto the stack, the runtime registers the operation with the OS, and then pops the call off the stack and keeps executing. The OS does the actual work — reading disk sectors, waiting for a network packet — while JavaScript's thread continues processing other code.
When the OS finishes the operation, it signals libuv, which places the callback you provided into the task queue (also called the macrotask queue or callback queue). The event loop then checks: if the call stack is empty, dequeue one task and push it onto the stack for execution.
import { readFile } from 'fs/promises';
import { createServer, IncomingMessage, ServerResponse } from 'http';
import path from 'path';
const ALLOWED_EXTENSIONS = new Set(['.json', '.txt', '.html']);
async function readSafeFile(filename: string, baseDir: string): Promise<string> {
const resolvedBase = path.resolve(baseDir);
const resolvedTarget = path.resolve(baseDir, filename);
if (!resolvedTarget.startsWith(resolvedBase)) {
throw new RangeError(`Path traversal attempt: ${filename}`);
}
const ext = path.extname(filename);
if (!ALLOWED_EXTENSIONS.has(ext)) {
throw new TypeError(`Disallowed file type: ${ext}`);
}
return readFile(resolvedTarget, 'utf8');
}
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const filename = url.searchParams.get('file') ?? '';
if (!filename) {
res.writeHead(400);
res.end('Missing file parameter');
return;
}
try {
const content = await readSafeFile(filename, path.join(process.cwd(), 'public'));
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(content);
} catch (err) {
if (err instanceof RangeError || err instanceof TypeError) {
res.writeHead(403);
res.end(err.message);
} else {
res.writeHead(500);
res.end('Internal server error');
}
}
});
server.listen(3000);
readFile from fs/promises returns a Promise backed by libuv's async file I/O. When you await it, JavaScript suspends execution of the async function and frees the call stack. The thread continues processing other requests, timer callbacks, or database responses. When the OS finishes reading the file, libuv places the Promise resolution into the microtask queue, and the async function resumes from where it paused.
Notice the path traversal guard. path.resolve canonicalizes the path before comparing it to the base directory. A filename like ../../etc/passwd resolves to a path outside baseDir, and the check catches it before touching the filesystem. Async I/O does not make your code safe by itself — it just makes it non-blocking. Security checks are still your responsibility.
The Microtask Queue: Higher Priority Than Everything Else
The task queue holds callbacks from timers, I/O, and system events. Promises use a separate, higher-priority queue: the microtask queue. The event loop drains the entire microtask queue before it picks up the next macrotask. Every .then() callback, every await continuation, every queueMicrotask() call lands in the microtask queue.
import { EventEmitter } from 'events';
class AsyncJobQueue extends EventEmitter {
private queue: Array<() => Promise<void>> = [];
private running = false;
enqueue(job: () => Promise<void>): void {
this.queue.push(job);
queueMicrotask(() => this.drain());
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const job = this.queue.shift()!;
try {
await job();
this.emit('job:complete');
} catch (err) {
this.emit('job:error', err);
}
}
this.running = false;
this.emit('queue:empty');
}
}
const jobQueue = new AsyncJobQueue();
jobQueue.on('job:complete', () => {
console.log('job finished');
});
jobQueue.on('queue:empty', () => {
console.log('all jobs processed');
});
jobQueue.on('job:error', (err: Error) => {
console.error('job failed:', err.message);
});
jobQueue.enqueue(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 100));
console.log('job A');
});
jobQueue.enqueue(async () => {
console.log('job B');
});
queueMicrotask(() => this.drain()) schedules drain as a microtask rather than calling it immediately. This matters for two reasons. First, it prevents re-entrant calls to drain when enqueue is called from inside a job. Second, if multiple jobs are enqueued synchronously in the same turn of the event loop, all of them land in this.queue before drain starts. drain then processes the batch rather than getting called once per job and thrashing the running lock.
The microtask queue runs to completion before the next macrotask. That means the drain microtask fires after all synchronous code in the current turn finishes, but before any setTimeout callback. The while loop inside drain keeps processing jobs as long as the queue has items. Each await job() pauses the async function, but between awaits the event loop can process other pending microtasks. It cannot process new I/O callbacks or timer callbacks until drain returns or hits an await.
Emitting events synchronously from within an async function is intentional here. EventEmitter.emit is synchronous — all listeners for job:complete run before emit returns. This makes the event timing predictable: listeners fire immediately after each job, not after the entire batch.
Execution Order: Putting the Queues Together
Understanding the exact order in which microtasks and macrotasks fire is not an academic exercise. It determines whether a cache invalidation fires before or after a database write, whether a cleanup handler runs before a response is sent, and whether a rate limit counter updates before or after you check it.
import { createClient } from 'redis';
type RequestPhase = 'start' | 'cache-miss' | 'db-query' | 'cache-set' | 'response';
interface TraceEvent {
phase: RequestPhase;
timestamp: number;
source: 'sync' | 'microtask' | 'macrotask';
}
async function traceRequestPipeline(
redis: ReturnType<typeof createClient>,
userId: number,
): Promise<TraceEvent[]> {
const trace: TraceEvent[] = [];
const now = () => performance.now();
trace.push({ phase: 'start', timestamp: now(), source: 'sync' });
const cached = await redis.get(`user:${userId}`);
if (cached !== null) {
trace.push({ phase: 'response', timestamp: now(), source: 'microtask' });
return trace;
}
trace.push({ phase: 'cache-miss', timestamp: now(), source: 'microtask' });
const dbResult = await new Promise<string>((resolve) => {
setTimeout(() => {
resolve(JSON.stringify({ id: userId, name: 'Alice' }));
}, 0);
});
trace.push({ phase: 'db-query', timestamp: now(), source: 'macrotask' });
await redis.set(`user:${userId}`, dbResult, { EX: 300 });
trace.push({ phase: 'cache-set', timestamp: now(), source: 'microtask' });
Promise.resolve().then(() => {
trace.push({ phase: 'response', timestamp: now(), source: 'microtask' });
});
return trace;
}
Walk through the execution order. The trace.push({ phase: 'start' }) call runs synchronously — it is plain JavaScript on the call stack. await redis.get(...) suspends the async function and surrenders the call stack. The Redis client sends a command over TCP and returns a Promise. When Redis responds, libuv resolves the Promise and queues the continuation as a microtask. The async function resumes at the if (cached !== null) line — this is a microtask continuation, so it runs before any pending setTimeout callbacks.
The setTimeout(() => resolve(...), 0) wrapping the simulated DB query places a callback in the macrotask queue. The async function awaits the Promise wrapping that callback. After the await, the event loop sees an empty call stack, drains microtasks (none pending), then picks up the setTimeout callback from the macrotask queue, which resolves the Promise. The async function's next continuation queues as a microtask and runs before any other macrotask.
This trace makes the queue priority visible. Everything after an await redis.get is a microtask continuation. Everything after an await new Promise(resolve => setTimeout(...)) is a macrotaskcontinuation followed immediately by a microtask continuation.
The Right Way to Handle Database I/O
Every await pool.query(...) in your application does not open a new TCP connection to your database. It borrows a connection from a pool, runs the query, and returns the connection. Opening a TCP connection costs a TLS handshake, a database authentication round-trip, and typically 20–100ms of wall-clock time. At 1,000 requests per second, paying that cost per query means 20–100 seconds of connection overhead per second of traffic — a math problem that cannot be solved by adding servers.
import { Pool, PoolClient, DatabaseError } from 'pg';
import { createLogger } from 'winston';
const logger = createLogger({ level: 'info' });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
min: 5,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 3_000,
statement_timeout: 10_000,
application_name: 'api-server',
});
pool.on('error', (err: Error) => {
logger.error({ err }, 'Idle client error in pool');
});
pool.on('connect', () => {
logger.info({ totalCount: pool.totalCount, idleCount: pool.idleCount }, 'Pool connection established');
});
interface TransactionResult<T> {
data: T;
durationMs: number;
}
async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<TransactionResult<T>> {
const client = await pool.connect();
const start = performance.now();
try {
await client.query('BEGIN');
const data = await fn(client);
await client.query('COMMIT');
return { data, durationMs: performance.now() - start };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
interface UserRow {
id: number;
username: string;
email: string;
role: 'admin' | 'member' | 'viewer';
created_at: Date;
}
interface AuditRow {
user_id: number;
action: string;
performed_by: number;
performed_at: Date;
}
async function promoteUserToAdmin(
userId: number,
requesterId: number,
): Promise<UserRow> {
const { data: user } = await withTransaction(async (client) => {
const userResult = await client.query<UserRow>(
`SELECT id, username, email, role, created_at
FROM users
WHERE id = $1
AND deleted_at IS NULL
FOR UPDATE`,
[userId],
);
if (userResult.rowCount === 0) {
throw Object.assign(new Error('User not found'), { code: 'USER_NOT_FOUND' });
}
const target = userResult.rows[0];
if (target.role === 'admin') {
throw Object.assign(new Error('User is already an admin'), { code: 'ALREADY_ADMIN' });
}
const updated = await client.query<UserRow>(
`UPDATE users
SET role = 'admin', updated_at = NOW()
WHERE id = $1
RETURNING id, username, email, role, created_at`,
[userId],
);
await client.query<AuditRow>(
`INSERT INTO audit_log (user_id, action, performed_by, performed_at)
VALUES ($1, $2, $3, NOW())`,
[userId, 'PROMOTE_TO_ADMIN', requesterId],
);
return updated.rows[0];
});
return user;
}
The withTransaction wrapper handles three things that production code requires and examples typically skip. First, it acquires a single client from the pool and holds it for the entire transaction. A BEGIN on one client followed by a query on a different client is not a transaction — it is two separate operations on two separate database sessions. The fn callback receives the specific client so every query inside the transaction goes to the same connection.
Second, the FOR UPDATE lock on the SELECT prevents a race condition where two concurrent requests both read the user as non-admin, both decide to promote, and both try to write. PostgreSQL serializes writers on a FOR UPDATE lock. The second concurrent request blocks at the SELECT FOR UPDATE until the first transaction commits or rolls back, then re-reads the updated row.
Third, the finally block releases the client unconditionally. If fn throws, ROLLBACK fires, then client.release() fires. Without finally, a thrown error returns the client to neither the transaction rollback nor the pool. The pool slowly exhausts its connections as each one leaks. Under sustained error conditions, the application stops accepting database connections entirely.
The statement_timeout: 10_000 in the pool configuration kills any query that takes longer than 10 seconds. Without this, a slow query holds its pool client indefinitely, and a small number of slow queries can exhaust the pool and cascade into full application unavailability.
Parallel vs. Sequential Await: Performance Implications
await inside a for loop runs each operation sequentially. The second iteration does not start until the first completes. This is correct when each operation depends on the previous result and catastrophically slow when operations are independent.
import { Pool } from 'pg';
import { createClient } from 'redis';
interface UserProfile {
id: number;
username: string;
email: string;
postsCount: number;
followersCount: number;
lastActiveAt: Date | null;
}
interface BatchProfileResult {
profiles: UserProfile[];
notFound: number[];
durationMs: number;
}
async function fetchUserProfilesBatch(
userIds: number[],
pool: Pool,
redis: ReturnType<typeof createClient>,
): Promise<BatchProfileResult> {
const start = performance.now();
if (userIds.length === 0) {
return { profiles: [], notFound: [], durationMs: 0 };
}
const deduplicated = [...new Set(userIds)];
const cacheKeys = deduplicated.map((id) => `profile:${id}`);
const cached = await redis.mGet(cacheKeys);
const cachedProfiles: UserProfile[] = [];
const uncachedIds: number[] = [];
cached.forEach((value, index) => {
if (value !== null) {
cachedProfiles.push(JSON.parse(value) as UserProfile);
} else {
uncachedIds.push(deduplicated[index]);
}
});
if (uncachedIds.length === 0) {
return {
profiles: cachedProfiles,
notFound: [],
durationMs: performance.now() - start,
};
}
const placeholders = uncachedIds.map((_, i) => `$${i + 1}`).join(', ');
const [usersResult, statsResult] = await Promise.all([
pool.query<Pick<UserProfile, 'id' | 'username' | 'email'>>(
`SELECT id, username, email
FROM users
WHERE id = ANY(ARRAY[${placeholders}]::int[])
AND deleted_at IS NULL`,
uncachedIds,
),
pool.query<{ user_id: number; posts_count: number; followers_count: number; last_active_at: Date | null }>(
`SELECT
user_id,
COUNT(DISTINCT p.id)::int AS posts_count,
COUNT(DISTINCT f.follower_id)::int AS followers_count,
MAX(s.last_active_at) AS last_active_at
FROM users u
LEFT JOIN posts p ON p.author_id = u.id AND p.deleted_at IS NULL
LEFT JOIN follows f ON f.followed_id = u.id
LEFT JOIN user_sessions s ON s.user_id = u.id
WHERE u.id = ANY(ARRAY[${placeholders}]::int[])
GROUP BY u.id`,
uncachedIds,
),
]);
const statsMap = new Map(
statsResult.rows.map((row) => [row.user_id, row]),
);
const foundIds = new Set(usersResult.rows.map((u) => u.id));
const notFound = uncachedIds.filter((id) => !foundIds.has(id));
const freshProfiles: UserProfile[] = usersResult.rows.map((user) => {
const stats = statsMap.get(user.id);
return {
id: user.id,
username: user.username,
email: user.email,
postsCount: stats?.posts_count ?? 0,
followersCount: stats?.followers_count ?? 0,
lastActiveAt: stats?.last_active_at ?? null,
};
});
if (freshProfiles.length > 0) {
const pipeline = redis.multi();
for (const profile of freshProfiles) {
pipeline.setEx(`profile:${profile.id}`, 300, JSON.stringify(profile));
}
await pipeline.exec();
}
return {
profiles: [...cachedProfiles, ...freshProfiles],
notFound,
durationMs: performance.now() - start,
};
}
The cache layer uses redis.mGet — one round-trip to Redis for all keys simultaneously — rather than a for loop calling redis.get for each key. For 50 user IDs, mGet costs one network round-trip. A for loop with await redis.get costs 50 sequential round-trips. On a Redis instance with 0.5ms latency, that difference is 0.5ms versus 25ms.
The database queries run through Promise.all. The users query and the aggregated stats query are independent — neither result feeds into the other query's parameters. Running them in parallel means the total database wait time equals the slower query rather than both queries added together. For queries averaging 8ms and 15ms respectively, sequential execution costs 23ms. Parallel execution costs 15ms.
The cache writes use a Redis pipeline. A multi() pipeline batches all SETEX commands into a single TCP write to Redis and reads a single response. Writing 20 cache entries sequentially costs 20 round-trips; pipelining costs one.
[...new Set(userIds)] deduplicates the input before any I/O. A caller requesting [1, 1, 2, 3, 3] should not trigger two identical cache checks and two identical database rows for user 1. Deduplication before the first await costs microseconds and prevents redundant I/O that scales with input size.
Promise.allSettled for Fault-Tolerant Concurrent Operations
Promise.all rejects immediately when any of its input Promises reject. For operations where partial success is acceptable — sending notifications, pre-warming caches, firing webhooks — you want results from every operation, not an all-or-nothing outcome.
import nodemailer from 'nodemailer';
import { Pool } from 'pg';
import { createLogger } from 'winston';
const logger = createLogger({ level: 'info' });
interface NotificationTarget {
userId: number;
email: string;
displayName: string;
}
interface NotificationPayload {
subject: string;
htmlBody: string;
textBody: string;
}
interface NotificationResult {
userId: number;
status: 'sent' | 'failed';
error?: string;
messageId?: string;
}
interface BulkNotificationReport {
total: number;
sent: number;
failed: number;
results: NotificationResult[];
durationMs: number;
}
async function sendBulkNotifications(
targets: NotificationTarget[],
payload: NotificationPayload,
pool: Pool,
transporter: nodemailer.Transporter,
concurrencyLimit = 25,
): Promise<BulkNotificationReport> {
const start = performance.now();
async function sendToUser(target: NotificationTarget): Promise<NotificationResult> {
const info = await transporter.sendMail({
from: `"${process.env.EMAIL_FROM_NAME}" <${process.env.EMAIL_FROM_ADDRESS}>`,
to: `"${target.displayName}" <${target.email}>`,
subject: payload.subject,
html: payload.htmlBody,
text: payload.textBody,
});
await pool.query(
`INSERT INTO notification_log (user_id, subject, message_id, sent_at)
VALUES ($1, $2, $3, NOW())`,
[target.userId, payload.subject, info.messageId],
);
return { userId: target.userId, status: 'sent', messageId: info.messageId };
}
const allResults: NotificationResult[] = [];
for (let i = 0; i < targets.length; i += concurrencyLimit) {
const batch = targets.slice(i, i + concurrencyLimit);
const settled = await Promise.allSettled(batch.map(sendToUser));
for (const outcome of settled) {
if (outcome.status === 'fulfilled') {
allResults.push(outcome.value);
} else {
const target = batch[settled.indexOf(outcome)];
const errorMessage = outcome.reason instanceof Error
? outcome.reason.message
: String(outcome.reason);
logger.warn(
{ userId: target.userId, email: target.email, error: errorMessage },
'Notification delivery failed',
);
allResults.push({
userId: target.userId,
status: 'failed',
error: errorMessage,
});
}
}
}
const sent = allResults.filter((r) => r.status === 'sent').length;
return {
total: targets.length,
sent,
failed: targets.length - sent,
results: allResults,
durationMs: performance.now() - start,
};
}
The outer for loop batches targets into groups of concurrencyLimit (default 25). Within each batch, Promise.allSettled fires all 25 sends concurrently and waits for all 25 to either fulfill or reject. The loop then starts the next batch of 25. This pattern bounds the number of in-flight SMTP connections and database queries at any given time.
Without the batch limit, targets.map(sendToUser) on 10,000 users creates 10,000 simultaneous SMTP connections. Most SMTP providers rate-limit or block at those volumes. A connection pool for SMTP typically caps at 5–10 connections. Opening 10,000 overwhelms it.
Promise.allSettled returns an array of result descriptors, each with a status field of either 'fulfilled' or 'rejected'. For 'fulfilled' results, outcome.value is the resolved value. For 'rejected' results, outcome.reason is the rejection reason. The settled array preserves the same order and length as the input array, so settled.indexOf(outcome) correctly maps a failed result back to its input target. This is important for logging — you need the user's ID and email to diagnose delivery failures.
The database insert inside sendToUser runs after the SMTP send. If the SMTP send throws, the insert never happens — you do not log a notification you did not send. If the SMTP send succeeds but the database insert throws, sendToUser rejects with the database error and allSettled records it as a failure. You sent the email but did not log it; the caller's report shows status: 'failed'. This is a trade-off. For strict audit requirements, you would use a transactional outbox pattern that records intent before sending and confirms delivery after.
Rate Limiting with Atomic Redis Operations
Rate limiting requires reading a counter, deciding whether to allow the request, and incrementing the counter — ideally as an atomic operation. Any gap between reading and writing is a race window.
import { createClient, Script } from 'redis';
import { createServer, IncomingMessage, ServerResponse } from 'http';
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfter?: number;
}
interface RateLimitConfig {
maxRequests: number;
windowSeconds: number;
}
const RATE_LIMIT_SCRIPT = `
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local count = redis.call('GET', key)
if count == false then
redis.call('SET', key, 1, 'EX', window)
return {1, max - 1, now + window, 0}
end
count = tonumber(count)
if count >= max then
local ttl = redis.call('TTL', key)
return {0, 0, now + ttl, now + ttl}
end
redis.call('INCR', key)
local ttl = redis.call('TTL', key)
return {1, max - count - 1, now + ttl, 0}
`;
class RateLimiter {
private readonly redis: ReturnType<typeof createClient>;
private readonly config: RateLimitConfig;
private scriptSha: string | null = null;
constructor(redis: ReturnType<typeof createClient>, config: RateLimitConfig) {
this.redis = redis;
this.config = config;
}
async check(identifier: string): Promise<RateLimitResult> {
const key = `rl:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const result = await this.redis.eval(RATE_LIMIT_SCRIPT, {
keys: [key],
arguments: [
this.config.maxRequests.toString(),
this.config.windowSeconds.toString(),
now.toString(),
],
}) as number[];
const [allowed, remaining, resetAt, retryAfter] = result;
return {
allowed: allowed === 1,
remaining,
resetAt,
retryAfter: retryAfter > 0 ? retryAfter : undefined,
};
}
}
function getClientIdentifier(req: IncomingMessage): string {
const forwarded = req.headers['x-forwarded-for'];
const ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded?.split(',')[0]?.trim() ?? req.socket.remoteAddress ?? 'unknown';
return ip;
}
function applyRateLimitHeaders(res: ServerResponse, result: RateLimitResult): void {
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', result.remaining.toString());
res.setHeader('X-RateLimit-Reset', result.resetAt.toString());
if (result.retryAfter !== undefined) {
res.setHeader('Retry-After', result.retryAfter.toString());
}
}
async function main(): Promise<void> {
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const limiter = new RateLimiter(redis, {
maxRequests: 100,
windowSeconds: 60,
});
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const identifier = getClientIdentifier(req);
try {
const result = await limiter.check(identifier);
applyRateLimitHeaders(res, result);
if (!result.allowed) {
res.writeHead(429, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'Too Many Requests',
retryAfter: result.retryAfter,
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500);
res.end('Internal server error');
}
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
}
main().catch(console.error);
The rate limit logic runs as a Lua script inside Redis via redis.eval. Redis executes Lua scripts atomically — the entire script runs to completion as a single Redis command. No other Redis command from any other client executes between the GET and the INCR. This eliminates the race condition entirely, not just reduces it.
A Node.js implementation that await redis.get(key) then checks the count then await redis.incr(key) has a race window between the get and the incr. Two concurrent requests both see count 99 (one below the limit of 100), both allow the request, both increment to 100 and 101. The script's atomic execution makes that impossible.
The script returns a four-element array: allowed flag (1 or 0), remaining requests, Unix timestamp of the window reset, and the retry-after value in seconds. The GET followed by SET ... EX on a missing key creates the counter with an expiry in one conditional branch. The INCR on an existing key increments without resetting the expiry, so the window duration is anchored to the first request in the window, not the most recent.
getClientIdentifier reads X-Forwarded-For before falling back to req.socket.remoteAddress. Behind a load balancer or reverse proxy, remoteAddress is the proxy's IP address — rate limiting on it throttles all users equally rather than individual clients. X-Forwarded-For carries the original client IP set by the proxy. Taking the first value from a comma-separated list is correct because proxies append their own IP, so the leftmost value is the original client (assuming you trust your proxy not to be spoofed upstream).
Async Error Propagation and the Express Error Handler
Express 4's router does not catch errors thrown from async functions. A thrown error inside an async route handler that lacks a try/catch hangs the request permanently — the client waits for a response that never comes, and the connection eventually times out.
import express, { Request, Response, NextFunction, RequestHandler } from 'express';
import { Pool, DatabaseError } from 'pg';
import { ZodError, z } from 'zod';
import { createLogger } from 'winston';
const logger = createLogger({ level: 'info' });
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
function wrapAsync(fn: AsyncHandler): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
) {
super(message);
this.name = 'AppError';
}
}
const CreateUserSchema = z.object({
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-z0-9_-]+$/, 'Username may only contain lowercase letters, numbers, underscores, and hyphens'),
email: z.string().email(),
role: z.enum(['member', 'viewer']),
});
interface CreatedUser {
id: number;
username: string;
email: string;
role: string;
created_at: Date;
}
function buildRouter(pool: Pool): express.Router {
const router = express.Router();
router.post(
'/users',
wrapAsync(async (req: Request, res: Response) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
throw new AppError(
'Validation failed',
400,
'VALIDATION_ERROR',
);
}
const { username, email, role } = parsed.data;
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1 OR username = $2 LIMIT 1',
[email, username],
);
if (existing.rowCount > 0) {
throw new AppError('Username or email already taken', 409, 'CONFLICT');
}
const result = await pool.query<CreatedUser>(
`INSERT INTO users (username, email, role, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, username, email, role, created_at`,
[username, email, role],
);
res.status(201).json({ user: result.rows[0] });
}),
);
return router;
}
function buildErrorHandler() {
return (err: unknown, req: Request, res: Response, _next: NextFunction): void => {
const requestId = req.headers['x-request-id'] ?? 'unknown';
if (err instanceof ZodError) {
logger.warn({ requestId, errors: err.errors }, 'Validation error');
res.status(400).json({
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: err.errors,
});
return;
}
if (err instanceof AppError) {
logger.warn({ requestId, code: err.code, message: err.message }, 'Application error');
res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
return;
}
if (err instanceof DatabaseError) {
if (err.code === '23505') {
res.status(409).json({ error: 'Conflict', code: 'DUPLICATE_KEY' });
return;
}
if (err.code === '57014') {
res.status(503).json({ error: 'Database timeout', code: 'DB_TIMEOUT' });
return;
}
logger.error({ requestId, pgCode: err.code, message: err.message }, 'Unhandled database error');
res.status(500).json({ error: 'Database error', code: 'DB_ERROR' });
return;
}
logger.error({ requestId, err }, 'Unhandled server error');
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
};
}
function buildApp(pool: Pool): express.Application {
const app = express();
app.use(express.json());
app.use('/api', buildRouter(pool));
app.use(buildErrorHandler());
return app;
}
export { buildApp, AppError };
wrapAsync is the linchpin. It wraps an async function and returns a standard Express RequestHandler. If the async function rejects — whether from a thrown AppError, a database error, a validation error, or any unexpected error — .catch(next) passes the rejection reason to Express's next function. Express detects that next was called with an argument and skips all remaining route handlers, routing directly to error-handling middleware. Error-handling middleware in Express is distinguished by its four-parameter signature (err, req, res, next).
Without wrapAsync, an async function that throws calls no error handler and sets no response. The socket stays open. Node.js does not forcibly close it. Depending on your HTTP client's timeout configuration, the connection hangs for 30–120 seconds before the client gives up. Under load, these hanging connections accumulate, exhaust Node.js's socket descriptors, and the server stops accepting new connections.
The error handler inspects the error type in order of specificity. ZodError means the route handler called safeParse and threw manually, or a Zod parse ran without safeParse. AppError means the application threw a known, typed error with a status code and business code. DatabaseError from pg carries PostgreSQL error codes in its code property — 23505 is unique_violation (a duplicate key constraint), 57014 is query_canceled (statement timeout hit). Anything else is an unknown error that warrants a 500 and a high-severity log entry.
Checking existing.rowCount before the insert is application-level duplicate detection. It is not a replacement for the database constraint — the database constraint enforces uniqueness even if two requests pass the application check simultaneously. The application check provides a clear error message. The database constraint catches the race. Both are necessary.
Graceful Shutdown: Draining the Event Loop
When a Node.js process receives SIGTERM — from Kubernetes scaling down a pod, a deploy restart, or an operator signal — you have two options. You can call process.exit(0) immediately and drop every in-flight request. Or you can stop accepting new connections, wait for in-flight requests to complete, close database connections cleanly, and then exit.
import { createServer, Server } from 'http';
import { Pool } from 'pg';
import { createClient } from 'redis';
import { createLogger } from 'winston';
const logger = createLogger({ level: 'info' });
interface ShutdownConfig {
gracePeriodMs: number;
hardKillMs: number;
}
class GracefulShutdown {
private readonly server: Server;
private readonly pool: Pool;
private readonly redis: ReturnType<typeof createClient>;
private readonly config: ShutdownConfig;
private isShuttingDown = false;
private activeConnections = new Set<import('net').Socket>();
constructor(
server: Server,
pool: Pool,
redis: ReturnType<typeof createClient>,
config: ShutdownConfig,
) {
this.server = server;
this.pool = pool;
this.redis = redis;
this.config = config;
this.server.on('connection', (socket) => {
this.activeConnections.add(socket);
socket.once('close', () => this.activeConnections.delete(socket));
});
}
register(): void {
process.once('SIGTERM', () => this.shutdown('SIGTERM'));
process.once('SIGINT', () => this.shutdown('SIGINT'));
process.once('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'Unhandled promise rejection — initiating shutdown');
this.shutdown('unhandledRejection');
});
}
private async shutdown(signal: string): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
logger.info({ signal }, 'Shutdown signal received — draining');
const hardKillTimer = setTimeout(() => {
logger.fatal('Graceful shutdown timed out — forcing exit');
process.exit(1);
}, this.config.hardKillMs);
hardKillTimer.unref();
await new Promise<void>((resolve) => {
this.server.close((err) => {
if (err) logger.error({ err }, 'Error closing HTTP server');
resolve();
});
});
logger.info(
{ activeConnections: this.activeConnections.size },
'HTTP server closed — waiting for active connections',
);
const graceDeadline = Date.now() + this.config.gracePeriodMs;
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (this.activeConnections.size === 0 || Date.now() >= graceDeadline) {
clearInterval(interval);
for (const socket of this.activeConnections) {
socket.destroy();
}
resolve();
}
}, 100);
});
logger.info('Active connections drained — closing dependencies');
await Promise.allSettled([
this.pool.end(),
this.redis.quit(),
]);
logger.info('Shutdown complete');
process.exit(0);
}
}
async function startServer(): Promise<void> {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
});
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const app = buildApp(pool);
const server = createServer(app);
const shutdown = new GracefulShutdown(server, pool, redis, {
gracePeriodMs: 10_000,
hardKillMs: 30_000,
});
shutdown.register();
server.listen(3000, () => {
logger.info('Server listening on port 3000');
});
}
startServer().catch((err) => {
console.error('Startup failed:', err);
process.exit(1);
});
server.close() stops the HTTP server from accepting new connections. Connections already established remain open. An HTTP keep-alive connection sitting idle between requests counts as an active connection. The polling loop every 100ms checks whether active connections have dropped to zero or whether the grace period has expired. Sockets that persist past the grace period get destroyed with socket.destroy(), which closes them without waiting for a graceful TCP close.
hardKillTimer.unref() is essential. Without it, the setTimeout holding the hard kill timer keeps the Node.js event loop alive even after all other work is done. unref() tells Node.js not to count this timer when deciding whether to exit naturally. The timer still fires if the process is still running when it expires; it just does not prevent the process from exiting before then.
Promise.allSettled on the pool end and Redis quit means both connections attempt to close regardless of whether one throws. Using Promise.all would mean a Redis disconnect error prevents the database pool from closing, leaving pooled connections open against the database after the process exits. The database then waits for those connections to time out before releasing their resources.
The unhandledRejection handler triggers the same shutdown path as SIGTERM. An unhandled rejection means the application reached a state it did not anticipate. Continuing to serve requests from a process in an unknown state is worse than restarting cleanly. Kubernetes or whatever process manager you use will restart the pod; graceful shutdown gives in-flight requests time to complete first.
The Mental Model
The event loop operates on a priority hierarchy. Synchronous JavaScript on the call stack runs first. When the stack empties, all queued microtasks drain — every pending Promise continuation, every queueMicrotask callback, every queued microtask added by those microtasks. Only after the microtask queue is completely empty does the event loop pull one macrotask from the task queue (a setTimeout, a setInterval, an I/O callback) and push it onto the call stack. Then the cycle repeats: run the stack, drain microtasks, pull one macrotask.
This ordering has concrete consequences for every async pattern in production code. Database query responses resume as microtasks. Timer callbacks fire as macrotasks. Deferring a state mutation to a macrotask with setTimeout(..., 0) instead of awaiting it directly creates a window where concurrent microtasks read stale state. Atomic operations in Redis or PostgreSQL close that window by moving the mutual exclusion into the database layer, where it belongs.
The event loop is not an abstraction over multi-threading. It is a single thread coordinating I/O through non-blocking OS calls and a priority-ordered queue system. Understanding which queue your callback lands in, and when that queue drains, tells you exactly when your code runs relative to everything else in the process. That knowledge is what separates code that works on a developer's machine from code that holds up under production load.