Node.js with TypeScript — Complete Backend Guide 2024

Sanjeev SharmaSanjeev Sharma
6 min read

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 &lt; 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 setImmediate or worker threads
  • Not handling unhandledRejection — causes silent failures in older Node versions
  • Using setTimeout(fn, 0) for immediate execution — use setImmediate or queueMicrotask instead
  • Ignoring backpressure when piping streams — always use pipeline() not manual .pipe()
  • Mixing async/await and .then() chains in the same function — choose one style

Best Practices

  • Always set up uncaughtException and unhandledRejection handlers before any other code
  • Use Promise.all() for independent async operations — never await in a loop
  • Prefer stream/promises pipeline over raw .pipe() for automatic error propagation
  • Implement graceful shutdown to drain in-flight requests before exiting
  • Profile the event loop with --prof flag 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/await compiles to Promise chains — the runtime behavior is identical
  • Graceful shutdown gives in-flight requests time to complete before the process exits

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading