Node.js with TypeScript — Complete Backend Guide 2024
Advertisement
Introduction
Why This Matters
Node.js powers some of the world's highest-traffic backend systems — from Netflix to LinkedIn — because its event-driven, non-blocking I/O model handles thousands of concurrent connections on a single thread. Understanding how this works at the level of the event loop is not academic; it directly impacts API latency, throughput, and reliability.
Combined with TypeScript, Node.js becomes a production-grade platform where async bugs, unhandled rejections, and type mismatches are caught at compile time instead of in production. In 2024, the Node.js + TypeScript combination is the de facto standard for backend JavaScript development.
This guide covers the core Node.js concepts every backend engineer must know: the event loop, async/await, streams, worker threads, and production error handling — all with TypeScript.
The Event Loop
Node.js is single-threaded but handles concurrency through non-blocking I/O and the event loop. Understanding the queue order matters for performance.
// Execution order example
console.log('1: Synchronous');
setTimeout(() => console.log('4: Macrotask (setTimeout)'), 0);
setImmediate(() => console.log('5: Check phase (setImmediate)'));
Promise.resolve().then(() => console.log('3: Microtask (Promise)'));
queueMicrotask(() => console.log('2.5: Microtask (queueMicrotask)'));
console.log('2: Synchronous');
// Output order:
// 1: Synchronous
// 2: Synchronous
// 2.5: Microtask (queueMicrotask)
// 3: Microtask (Promise)
// 4: Macrotask (setTimeout)
// 5: Check phase (setImmediate)Rule: Synchronous code runs first, then microtasks (Promises, queueMicrotask), then macrotasks (setTimeout, setImmediate, I/O callbacks).
Async/Await Patterns
interface User {
id: string;
name: string;
email: string;
}
// Sequential execution — only use when each step depends on the previous
async function createUserWithAudit(data: Omit<User, 'id'>): Promise<User> {
const user = await db.users.create(data); // Step 1: create
await auditLog.record('user.created', user.id); // Step 2: log (requires user.id)
await emailService.sendWelcome(user.email); // Step 3: email
return user;
}
// Parallel execution — use when steps are independent
async function getUserDashboard(userId: string) {
const [user, orders, notifications] = await Promise.all([
db.users.findById(userId),
db.orders.findByUserId(userId),
db.notifications.findUnread(userId),
]);
return { user, orders, notifications };
}
// Race with timeout
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
);
return Promise.race([promise, timeout]);
}Error Handling
Never let unhandled rejections crash your process silently.
// Global handlers — always register these
process.on('uncaughtException', (error: Error) => {
console.error('[uncaughtException]', error);
process.exit(1);
});
process.on('unhandledRejection', (reason: unknown) => {
console.error('[unhandledRejection]', reason);
process.exit(1);
});
// Typed error hierarchy
class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number = 500,
public readonly code: string = 'INTERNAL_ERROR'
) {
super(message);
this.name = 'AppError';
}
}
// Async wrapper to avoid try/catch boilerplate in Express
function asyncHandler<T>(
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
) {
return (req: Request, res: Response, next: NextFunction): void => {
fn(req, res, next).catch(next);
};
}Node.js Streams with TypeScript
Streams are the correct tool for large files, log processing, and HTTP proxying — they avoid loading everything into memory at once.
import { Transform, TransformCallback } from 'stream';
import { pipeline } from 'stream/promises';
import fs from 'fs';
// Custom transform stream with TypeScript
class JsonLineParser extends Transform {
private buffer = '';
constructor() {
super({ objectMode: true });
}
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
this.buffer += chunk.toString();
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
try {
this.push(JSON.parse(trimmed));
} catch {
this.emit('error', new Error(`Invalid JSON: ${trimmed}`));
}
}
}
callback();
}
_flush(callback: TransformCallback): void {
if (this.buffer.trim()) {
try {
this.push(JSON.parse(this.buffer));
} catch {
this.emit('error', new Error(`Invalid JSON in buffer: ${this.buffer}`));
}
}
callback();
}
}
// Use the pipeline API for proper backpressure and cleanup
async function processLargeJsonFile(inputPath: string, outputPath: string): Promise<void> {
await pipeline(
fs.createReadStream(inputPath),
new JsonLineParser(),
async function* (source) {
for await (const record of source) {
yield JSON.stringify({ ...record, processed: true }) + '\n';
}
},
fs.createWriteStream(outputPath)
);
}Worker Threads for CPU-Intensive Work
CPU-bound tasks block the event loop. Offload them to worker threads.
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { cpus } from 'os';
// worker.ts
if (!isMainThread) {
const { data } = workerData as { data: number[] };
const result = data.reduce((sum, n) => sum + n, 0);
parentPort?.postMessage({ result });
}
// main.ts — pool of workers
class WorkerPool {
private workers: Worker[] = [];
private queue: Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void; data: unknown }> = [];
private available: Worker[] = [];
constructor(workerPath: string, size: number = cpus().length) {
for (let i = 0; i < size; i++) {
const worker = new Worker(workerPath);
worker.on('message', (result) => {
const task = this.queue.shift();
if (task) {
task.resolve(result);
this.available.push(worker);
this.drain();
}
});
this.workers.push(worker);
this.available.push(worker);
}
}
private drain(): void {
while (this.queue.length > 0 && this.available.length > 0) {
const worker = this.available.pop()!;
const task = this.queue.shift()!;
worker.postMessage({ data: task.data });
}
}
run(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
this.queue.push({ resolve, reject, data });
this.drain();
});
}
}Graceful Shutdown
function setupGracefulShutdown(server: import('http').Server): void {
const shutdown = (signal: string) => {
console.log(`Received ${signal}, shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
// Close database connections, flush logs, etc.
process.exit(0);
});
// Force close after 10 seconds
setTimeout(() => {
console.error('Forced shutdown');
process.exit(1);
}, 10_000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}Common Mistakes
- Blocking the event loop with synchronous CPU-intensive work — use
setImmediateor worker threads - Not handling
unhandledRejection— causes silent failures in older Node versions - Using
setTimeout(fn, 0)for immediate execution — usesetImmediateorqueueMicrotaskinstead - Ignoring backpressure when piping streams — always use
pipeline()not manual.pipe() - Mixing
async/awaitand.then()chains in the same function — choose one style
Best Practices
- Always set up
uncaughtExceptionandunhandledRejectionhandlers before any other code - Use
Promise.all()for independent async operations — neverawaitin a loop - Prefer
stream/promisespipeline over raw.pipe()for automatic error propagation - Implement graceful shutdown to drain in-flight requests before exiting
- Profile the event loop with
--profflag or clinic.js before optimizing
Key Takeaways
- Node.js uses a single-threaded event loop with separate queues for microtasks and macrotasks
- Promises and queueMicrotask run in the microtask queue — before any setTimeout or setImmediate
Promise.all()runs independent async operations in parallel — dramatically faster than sequential awaits- Streams handle large data efficiently via backpressure — always use
pipeline()not manual.pipe() - Worker threads are the correct tool for CPU-bound work that would block the event loop
process.on('unhandledRejection')is mandatory in production — unhandled rejections crash processes- TypeScript's
async/awaitcompiles to Promise chains — the runtime behavior is identical - Graceful shutdown gives in-flight requests time to complete before the process exits
Advertisement