Table of Contents

1. Multimedia & Binary Data (Blob, File, MIME, Base64) 2. Node.js File System & Path 3. Buffers in Node.js 4. Streams in Node.js 5. Database Essentials: Transactions, Indexes & Scale 6. Chunked Reading & Streaming Servers 7. Scalability Patterns 8. Backend Design Patterns

1. Multimedia & Binary Data (Blob, File, MIME, Base64)

Every app eventually deals with files -- images, videos, PDFs, audio. Understanding how binary data moves between the browser, your API, and storage is non-negotiable for a backend engineer. This section covers the fundamental building blocks.

Why This Matters for Your Career

Most tutorials skip this. Then you get a job and your first ticket is "users can upload profile pictures up to 5MB." Suddenly you need to understand MIME types, multipart form data, base64 encoding, file size validation, and streaming uploads. This section gives you the mental model so none of that feels foreign.

What is Binary Data?

Everything on a computer is binary -- ones and zeros. Text files are binary with a nice encoding (UTF-8) that maps bytes to characters. But images, videos, and audio are raw binary -- there is no "human-readable" version. You work with them as sequences of bytes.

Text vs Binary -- The Key Difference

A text file containing "Hello" is 5 bytes: 48 65 6C 6C 6F (hex). Each byte maps to a letter.

A PNG image's first 8 bytes are always: 89 50 4E 47 0D 0A 1A 0A -- this is the "magic number" that tells programs "I'm a PNG." The rest is compressed pixel data that only image decoders understand.

The takeaway: You can console.log() text and read it. You cannot console.log() an image and get anything useful. Binary data requires specialized handling.

Blob (Binary Large Object)

A Blob is the browser's way of representing raw binary data. It's an immutable chunk of bytes with a MIME type attached. You cannot read a Blob directly -- you must use APIs to extract its contents.

Creating and Using Blobs in the Browser
// Creating a Blob from text
const textBlob = new Blob(["Hello, World!"], { type: "text/plain" });
console.log(textBlob.size);  // 13 bytes
console.log(textBlob.type);  // "text/plain"

// Creating a Blob from JSON
const data = { name: "Sean", role: "developer" };
const jsonBlob = new Blob([JSON.stringify(data)], { type: "application/json" });

// Creating a Blob from binary data (Uint8Array)
const bytes = new Uint8Array([72, 101, 108, 108, 111]);  // "Hello"
const binaryBlob = new Blob([bytes], { type: "application/octet-stream" });

// Reading a Blob's contents
const text = await textBlob.text();       // "Hello, World!"
const buffer = await textBlob.arrayBuffer(); // Raw bytes as ArrayBuffer

// Slicing a Blob (like substring but for binary)
const partial = textBlob.slice(0, 5, "text/plain");  // First 5 bytes
When You Use Blobs in Real Apps
  • File uploads: When a user selects a file via <input type="file">, you get a File object (which extends Blob)
  • Download links: URL.createObjectURL(blob) creates a temporary URL to trigger downloads
  • Canvas exports: canvas.toBlob() gives you the image as a Blob for uploading
  • Fetch responses: response.blob() gets binary data from API responses

The File API

The File object extends Blob, adding name, lastModified, and webkitRelativePath. When a user picks a file from their computer, the browser gives you a File object.

Handling File Uploads -- The Complete Pattern
// HTML: <input type="file" id="upload" accept="image/*" multiple>

const input = document.getElementById("upload");

input.addEventListener("change", async (event) => {
  const files = event.target.files;  // FileList (array-like)

  for (const file of files) {
    console.log(file.name);          // "photo.jpg"
    console.log(file.size);          // 2458832 (bytes)
    console.log(file.type);          // "image/jpeg"
    console.log(file.lastModified);  // 1708123456789 (timestamp)

    // Validate before uploading
    if (file.size > 5 * 1024 * 1024) {
      alert("File too large! Max 5MB.");
      continue;
    }

    if (!file.type.startsWith("image/")) {
      alert("Only images allowed!");
      continue;
    }

    // Option 1: Upload as FormData (multipart -- most common)
    const formData = new FormData();
    formData.append("avatar", file);
    formData.append("userId", "123");
    await fetch("/api/upload", { method: "POST", body: formData });

    // Option 2: Upload as raw binary
    await fetch("/api/upload", {
      method: "POST",
      headers: { "Content-Type": file.type },
      body: file,  // File IS a Blob, so this works
    });

    // Option 3: Read as base64 for preview
    const reader = new FileReader();
    reader.onload = () => {
      const base64 = reader.result;  // "data:image/jpeg;base64,/9j/4AAQ..."
      document.getElementById("preview").src = base64;
    };
    reader.readAsDataURL(file);
  }
});

MIME Types

MIME (Multipurpose Internet Mail Extensions) types tell systems what kind of data they're looking at. Format: type/subtype. Getting MIME types wrong causes real bugs -- browsers won't display images, downloads get corrupted, security filters reject uploads.

Common MIME Types You Must Know
// Text formats
"text/plain"                  // .txt
"text/html"                   // .html
"text/css"                    // .css
"text/javascript"             // .js (also "application/javascript")
"text/csv"                    // .csv

// Application formats
"application/json"            // .json -- APIs
"application/pdf"             // .pdf
"application/xml"             // .xml
"application/zip"             // .zip
"application/octet-stream"    // Generic binary (unknown type)

// Images
"image/jpeg"                  // .jpg, .jpeg
"image/png"                   // .png
"image/gif"                   // .gif
"image/webp"                  // .webp (modern, smaller)
"image/svg+xml"               // .svg (vector graphics)

// Audio
"audio/mpeg"                  // .mp3
"audio/wav"                   // .wav
"audio/ogg"                   // .ogg

// Video
"video/mp4"                   // .mp4
"video/webm"                  // .webm
"video/ogg"                   // .ogv

// Multipart (for form uploads)
"multipart/form-data"         // File uploads via forms
Security: Never Trust Client-Sent MIME Types

A user can rename malware.exe to photo.jpg and the browser will send image/jpeg as the MIME type. Always validate on the server by checking the file's magic bytes (first few bytes), not the extension or MIME header.

// Server-side: check magic bytes for real file type
// JPEG starts with: FF D8 FF
// PNG starts with:  89 50 4E 47
// PDF starts with:  25 50 44 46 ("%PDF")
// GIF starts with:  47 49 46 38 ("GIF8")

const buf = Buffer.from(fileData);
const isJPEG = buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF;
const isPNG  = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47;

Base64 Encoding

Base64 converts binary data into ASCII text using 64 characters (A-Z, a-z, 0-9, +, /). Why? Because many systems (JSON, HTML, email, URLs) can only handle text, not raw bytes. Base64 is the bridge.

Base64 in Practice
// Browser: encoding and decoding
const encoded = btoa("Hello, World!");       // "SGVsbG8sIFdvcmxkIQ=="
const decoded = atob("SGVsbG8sIFdvcmxkIQ=="); // "Hello, World!"

// Node.js: Buffer handles base64 natively
const buf = Buffer.from("Hello, World!");
const b64 = buf.toString("base64");          // "SGVsbG8sIFdvcmxkIQ=="
const original = Buffer.from(b64, "base64").toString("utf-8"); // "Hello, World!"

// Data URLs: embed binary in HTML/CSS (small images only!)
// Format: data:[MIME];base64,[DATA]
const imgTag = `<img src="data:image/png;base64,iVBORw0KGgo..." />`;

// Converting a file to base64 for JSON APIs
const file = fs.readFileSync("photo.jpg");
const payload = {
  filename: "photo.jpg",
  content: file.toString("base64"),  // Now it's a JSON-safe string
  mime: "image/jpeg"
};
Base64 Overhead: 33% Larger

Base64 encoding increases data size by ~33%. A 3MB image becomes ~4MB as base64. This is why you should never use base64 for large file transfers -- use multipart form data or streams instead. Base64 is best for small assets (icons, thumbnails) or when you must embed binary data inside JSON/HTML.

ArrayBuffer and TypedArrays

Under the hood, binary data in JavaScript lives in ArrayBuffers -- fixed-length chunks of raw memory. You read/write them through TypedArrays (views into the buffer).

Working with ArrayBuffers
// Create a buffer of 16 bytes
const buffer = new ArrayBuffer(16);

// View it as unsigned 8-bit integers (0-255 per byte)
const uint8 = new Uint8Array(buffer);
uint8[0] = 72;   // 'H'
uint8[1] = 101;  // 'e'
uint8[2] = 108;  // 'l'
uint8[3] = 108;  // 'l'
uint8[4] = 111;  // 'o'

// View the SAME buffer as 32-bit integers
const uint32 = new Uint32Array(buffer);
console.log(uint32[0]);  // Combines first 4 bytes into one 32-bit number

// Common TypedArrays:
// Uint8Array    -- bytes (0 to 255) -- most common for file I/O
// Int8Array     -- signed bytes (-128 to 127)
// Uint16Array   -- 2-byte unsigned (0 to 65535)
// Int32Array    -- 4-byte signed
// Float32Array  -- 4-byte floats (for audio/graphics)
// Float64Array  -- 8-byte doubles (for precision math)

// Converting between Blob and ArrayBuffer
const blob = new Blob([uint8], { type: "application/octet-stream" });
const backToBuffer = await blob.arrayBuffer();
const backToUint8 = new Uint8Array(backToBuffer);

Multipart Form Data -- How File Uploads Actually Work

When you submit a form with files, the browser sends a multipart/form-data request. The body is divided into "parts" separated by a boundary string. Each part has its own headers and content. This is how most file uploads work on the web.

What a Multipart Request Looks Like on the Wire
POST /api/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

sean
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg

[RAW BINARY BYTES OF THE IMAGE HERE]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

The boundary string separates each field. Text fields send their value directly. File fields include the filename, MIME type, and raw binary content. Your backend framework (Express, Fastify, etc.) parses this for you using libraries like multer or busboy.

Express.js File Upload with Multer -- Production Pattern
const express = require("express");
const multer = require("multer");
const path = require("path");

// Configure where and how files are stored
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./uploads/");  // Save to uploads directory
  },
  filename: (req, file, cb) => {
    // Unique name: timestamp-randomhex.extension
    const uniqueName = Date.now() + "-" + crypto.randomBytes(6).toString("hex");
    const ext = path.extname(file.originalname);  // ".jpg"
    cb(null, uniqueName + ext);
  }
});

// Validation: only images, max 5MB
const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 },  // 5MB
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Only JPEG, PNG, and WebP allowed"));
    }
  }
});

const app = express();

// Single file upload
app.post("/api/avatar", upload.single("avatar"), (req, res) => {
  console.log(req.file);
  // { fieldname: 'avatar', originalname: 'photo.jpg',
  //   mimetype: 'image/jpeg', size: 245883,
  //   destination: './uploads/', filename: '1708123456789-a1b2c3.jpg',
  //   path: 'uploads/1708123456789-a1b2c3.jpg' }
  res.json({ url: `/uploads/${req.file.filename}` });
});

// Multiple files
app.post("/api/gallery", upload.array("photos", 10), (req, res) => {
  console.log(req.files);  // Array of file objects (max 10)
  res.json({ count: req.files.length });
});

// Error handling for multer
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      return res.status(413).json({ error: "File too large. Max 5MB." });
    }
  }
  res.status(400).json({ error: err.message });
});
File Storage: Local vs Cloud
  • Local filesystem -- Fine for dev and small apps. Breaks when you have multiple servers (load balancing) because files only exist on one machine.
  • Cloud object storage (S3, GCS, R2) -- The production standard. Files are accessible from any server, automatically replicated, and you get CDN integration for fast delivery.
  • Database (BLOB column) -- Almost never do this. Databases are optimized for structured data, not large binary blobs. It makes backups huge and queries slow.
  • The pattern: Upload to cloud storage, save the URL/key in your database. Serve via CDN.

2. Node.js File System & Path

The fs (file system) and path modules are how Node.js interacts with the operating system's files and directories. Every backend engineer uses these daily -- reading config files, writing logs, processing uploads, generating reports.

Sync vs Async -- This Will Bite You

The fs module offers three flavors of every operation:

  • fs.readFileSync() -- Blocks the entire event loop. Use only at startup (reading config). Never in request handlers.
  • fs.readFile(path, callback) -- Callback-based async. Works but leads to callback hell.
  • fs.promises.readFile() -- Promise-based async. This is what you should use.

If you use readFileSync inside an Express route handler, your server can only handle one request at a time while the file is being read. For a 100ms disk read, that's 100ms where every other user is waiting. With 100 concurrent users, the last one waits 10 seconds. Use async.

Reading Files

Three Ways to Read Files
const fs = require("fs");
const fsPromises = require("fs/promises");  // or fs.promises

// 1. Synchronous -- blocks event loop (only use at startup)
const configSync = fs.readFileSync("./config.json", "utf-8");
const config = JSON.parse(configSync);

// 2. Callback -- works but messy
fs.readFile("./data.txt", "utf-8", (err, data) => {
  if (err) {
    console.error("Failed to read:", err.message);
    return;
  }
  console.log(data);
});

// 3. Promises -- the modern way (use this)
async function loadData() {
  try {
    const data = await fsPromises.readFile("./data.txt", "utf-8");
    console.log(data);
  } catch (err) {
    if (err.code === "ENOENT") {
      console.log("File not found");
    } else {
      throw err;  // Re-throw unexpected errors
    }
  }
}

// Reading binary files (no encoding = returns Buffer)
const imageBuffer = await fsPromises.readFile("./photo.jpg");
console.log(imageBuffer.length);  // Size in bytes
console.log(imageBuffer[0]);      // First byte (0xFF for JPEG)

Writing Files

Writing and Appending
const fsPromises = require("fs/promises");

// Write (creates or overwrites)
await fsPromises.writeFile("./output.txt", "Hello, World!", "utf-8");

// Write JSON
const data = { users: 150, active: true };
await fsPromises.writeFile("./data.json", JSON.stringify(data, null, 2));

// Append (adds to end of file)
await fsPromises.appendFile("./log.txt", `[${new Date().toISOString()}] Server started\n`);

// Write binary data
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);  // PNG header
await fsPromises.writeFile("./header.bin", buffer);

// Write with flags
const { open } = require("fs/promises");
const fileHandle = await open("./output.txt", "w");  // 'w' = write, 'a' = append
await fileHandle.write("Line 1\n");
await fileHandle.write("Line 2\n");
await fileHandle.close();  // Always close file handles!

The Path Module -- Never Hardcode Paths

Path Operations You Use Every Day
const path = require("path");

// Joining paths safely (handles slashes for you)
path.join("/users", "sean", "documents", "file.txt");
// "/users/sean/documents/file.txt"

// Resolving to absolute path
path.resolve("./uploads", "photo.jpg");
// "/home/sean/project/uploads/photo.jpg" (from current working dir)

// Getting parts of a path
path.basename("/uploads/photo.jpg");           // "photo.jpg"
path.basename("/uploads/photo.jpg", ".jpg");   // "photo" (without ext)
path.extname("/uploads/photo.jpg");            // ".jpg"
path.dirname("/uploads/photo.jpg");            // "/uploads"

// Parsing a path into its components
path.parse("/home/sean/photo.jpg");
// { root: '/', dir: '/home/sean', base: 'photo.jpg',
//   ext: '.jpg', name: 'photo' }

// __dirname and __filename (CommonJS)
console.log(__dirname);   // Directory of current file
console.log(__filename);  // Full path of current file

// ES Modules equivalent
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Why this matters: NEVER do this
const bad = "./uploads/" + userInput;  // Path traversal attack!
// User sends "../../../etc/passwd" and reads your system files

// ALWAYS sanitize and resolve
const safePath = path.join("./uploads", path.basename(userInput));
// path.basename strips directory traversal: "../../../etc/passwd" -> "passwd"

Directory Operations

Creating, Reading, and Managing Directories
const fsPromises = require("fs/promises");
const path = require("path");

// Create directory (recursive: true creates parent dirs too)
await fsPromises.mkdir("./uploads/avatars/thumbnails", { recursive: true });

// List directory contents
const files = await fsPromises.readdir("./uploads");
console.log(files);  // ["photo1.jpg", "photo2.png", "avatars"]

// List with file type info
const entries = await fsPromises.readdir("./uploads", { withFileTypes: true });
for (const entry of entries) {
  if (entry.isFile()) console.log("File:", entry.name);
  if (entry.isDirectory()) console.log("Dir:", entry.name);
}

// Check if file/directory exists
async function exists(filePath) {
  try {
    await fsPromises.access(filePath);
    return true;
  } catch {
    return false;
  }
}

// Get file metadata
const stats = await fsPromises.stat("./photo.jpg");
console.log(stats.size);          // Size in bytes
console.log(stats.isFile());      // true
console.log(stats.isDirectory()); // false
console.log(stats.mtime);         // Last modified date
console.log(stats.birthtime);     // Created date

// Delete file
await fsPromises.unlink("./temp/old-file.txt");

// Delete directory (recursive: removes contents too)
await fsPromises.rm("./temp", { recursive: true, force: true });

// Rename / Move file
await fsPromises.rename("./old-name.txt", "./new-name.txt");
await fsPromises.rename("./uploads/temp.jpg", "./uploads/avatars/final.jpg");

// Copy file
await fsPromises.copyFile("./source.jpg", "./backup/source.jpg");

// Watch for changes (useful for dev tools, hot reload)
const { watch } = require("fs/promises");
const watcher = watch("./src", { recursive: true });
for await (const event of watcher) {
  console.log(event.eventType, event.filename);  // "change" "index.js"
}
Common fs Error Codes
  • ENOENT -- File or directory not found (most common)
  • EACCES -- Permission denied (check file ownership)
  • EISDIR -- Expected a file but got a directory
  • ENOTDIR -- Expected a directory but got a file
  • EEXIST -- File already exists (when using exclusive create)
  • EMFILE -- Too many open files (you're leaking file handles)
  • ENOSPC -- No space left on device (disk full)

3. Buffers in Node.js

A Buffer is Node.js's way of handling raw binary data. Unlike JavaScript strings (which are UTF-16 encoded), Buffers are sequences of raw bytes. When you read a file without specifying an encoding, you get a Buffer. When you receive data over a network socket, it arrives as Buffers. They are everywhere in backend code.

Buffer vs ArrayBuffer

Buffer is Node.js-specific. ArrayBuffer is the browser standard. Buffer actually extends Uint8Array under the hood, so they share many methods. In Node.js, always use Buffer. In the browser, use ArrayBuffer/TypedArrays. When working in environments that support both (like Deno or modern Node), Buffer is still the conventional choice on the server.

Creating Buffers

Every Way to Create a Buffer
// From a string (most common)
const buf1 = Buffer.from("Hello, World!", "utf-8");
console.log(buf1);        // <Buffer 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21>
console.log(buf1.length); // 13 bytes

// From an array of bytes
const buf2 = Buffer.from([72, 101, 108, 108, 111]);  // "Hello"

// From hex string
const buf3 = Buffer.from("48656c6c6f", "hex");  // "Hello"

// From base64
const buf4 = Buffer.from("SGVsbG8=", "base64");  // "Hello"

// Allocate empty buffer (filled with zeros)
const buf5 = Buffer.alloc(1024);      // 1KB of zeros -- SAFE
const buf6 = Buffer.allocUnsafe(1024); // 1KB, may contain old memory data -- FAST

// Why allocUnsafe exists: alloc fills memory with zeros (takes time).
// allocUnsafe skips zeroing (faster) but may expose data from
// previously freed memory. Use alloc for security-sensitive data,
// allocUnsafe when you'll immediately overwrite all bytes.

Reading From Buffers

Extracting Data from Buffers
const buf = Buffer.from("Hello, World!");

// Convert to string
buf.toString("utf-8");     // "Hello, World!"
buf.toString("hex");       // "48656c6c6f2c20576f726c6421"
buf.toString("base64");    // "SGVsbG8sIFdvcmxkIQ=="

// Read individual bytes
buf[0];  // 72 (the byte value of 'H')
buf[1];  // 101 ('e')

// Slice (does NOT copy -- shares memory!)
const slice = buf.slice(0, 5);    // Buffer: "Hello"
slice[0] = 74;                     // Changes BOTH buf and slice!
console.log(buf.toString());       // "Jello, World!" -- buf was modified!

// subarray (same as slice, shares memory)
const sub = buf.subarray(7, 12);   // Buffer: "World"

// To get an independent copy, use Buffer.from(slice)
const copy = Buffer.from(buf.slice(0, 5));  // Independent copy

// Reading numbers from binary data (network protocols, file formats)
const data = Buffer.alloc(8);
data.writeUInt32BE(0x12345678, 0);  // Write 4-byte big-endian at offset 0
data.writeUInt16LE(0xABCD, 4);     // Write 2-byte little-endian at offset 4
data.readUInt32BE(0);               // 0x12345678
data.readUInt16LE(4);               // 0xABCD

// BE = Big Endian (most significant byte first) -- network byte order
// LE = Little Endian (least significant byte first) -- x86 CPUs

Buffer Operations

Common Buffer Manipulations
// Concatenating buffers
const part1 = Buffer.from("Hello, ");
const part2 = Buffer.from("World!");
const combined = Buffer.concat([part1, part2]);
// <Buffer 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21>

// Comparing buffers
const a = Buffer.from("abc");
const b = Buffer.from("abc");
const c = Buffer.from("abd");
a.equals(b);     // true (same content)
a.equals(c);     // false
Buffer.compare(a, c);  // -1 (a comes before c)

// Searching in buffers
const buf = Buffer.from("Hello, World!");
buf.indexOf("World");   // 7 (byte offset)
buf.includes("World");  // true
buf.indexOf(0x57);      // 7 (byte value of 'W')

// Filling a buffer
const zeroed = Buffer.alloc(10);
zeroed.fill(0xFF);      // All bytes set to 255
zeroed.fill("ab");      // Repeating pattern: 61 62 61 62 61 62...

// Iterating over bytes
for (const byte of buf) {
  process.stdout.write(byte.toString(16) + " ");
}
// 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21
When You Use Buffers in Real Backend Code
  • File I/O: fs.readFile(path) without encoding returns a Buffer
  • Crypto: crypto.randomBytes(32) returns a Buffer (for tokens, salts, IVs)
  • Network: TCP sockets receive data as Buffers
  • Image processing: Libraries like Sharp take and return Buffers
  • Hashing: crypto.createHash("sha256").update(buf).digest() works with Buffers
  • Protocol parsing: Reading binary protocols (WebSocket frames, HTTP/2, database wire protocols)

4. Streams in Node.js

Streams let you process data piece by piece instead of loading everything into memory at once. This is the difference between an app that crashes on a 2GB file and one that handles it effortlessly with 50MB of RAM. Streams are the backbone of scalable Node.js.

Why Streams Are Non-Negotiable for Scale

Imagine 100 users simultaneously uploading 100MB files.

  • Without streams: readFile() loads each entire file into memory. 100 users x 100MB = 10GB RAM. Your server crashes.
  • With streams: Each upload processes in small chunks (16KB-64KB at a time). 100 users x 64KB = 6.4MB RAM. Your server is fine.

Streams are not an optimization. They are how you build software that doesn't fall over under load.

The Four Types of Streams

Stream Types and Real-World Examples
// 1. READABLE -- Data comes OUT of it (source)
//    Examples: fs.createReadStream, HTTP request body, process.stdin
const readable = fs.createReadStream("./bigfile.csv");

// 2. WRITABLE -- Data goes INTO it (destination)
//    Examples: fs.createWriteStream, HTTP response, process.stdout
const writable = fs.createWriteStream("./output.txt");

// 3. TRANSFORM -- Data goes in, different data comes out (processor)
//    Examples: zlib.createGzip(), crypto.createCipher(), CSV parser
const gzip = zlib.createGzip();

// 4. DUPLEX -- Both readable AND writable (independent)
//    Examples: TCP sockets, WebSockets
const socket = new net.Socket();

Reading with Streams

Processing Large Files Without Loading Them Entirely
const fs = require("fs");

// Create a readable stream (default chunk size: 64KB)
const stream = fs.createReadStream("./server.log", {
  encoding: "utf-8",
  highWaterMark: 64 * 1024,  // 64KB chunks (default)
});

// Event-based reading
stream.on("data", (chunk) => {
  console.log(`Received ${chunk.length} bytes`);
  // Process each chunk -- this fires many times for large files
});

stream.on("end", () => {
  console.log("Done reading");
});

stream.on("error", (err) => {
  console.error("Read error:", err.message);
});

// Modern async iteration (cleaner -- use this)
async function processFile() {
  const stream = fs.createReadStream("./server.log", { encoding: "utf-8" });

  for await (const chunk of stream) {
    // Process each chunk
    const lines = chunk.split("\n");
    for (const line of lines) {
      if (line.includes("ERROR")) {
        console.log("Found error:", line);
      }
    }
  }
}

Writing with Streams

Writing Large Amounts of Data Efficiently
const fs = require("fs");

const writeStream = fs.createWriteStream("./output.csv");

// Write header
writeStream.write("id,name,email\n");

// Write 1 million rows without running out of memory
for (let i = 0; i < 1_000_000; i++) {
  const row = `${i},user_${i},user${i}@example.com\n`;

  // write() returns false when internal buffer is full
  const canContinue = writeStream.write(row);

  if (!canContinue) {
    // BACKPRESSURE: internal buffer is full
    // Wait for it to drain before writing more
    await new Promise((resolve) => writeStream.once("drain", resolve));
  }
}

// Signal that we're done writing
writeStream.end();

// Wait for all data to be flushed to disk
writeStream.on("finish", () => {
  console.log("All data written to disk");
});

Piping -- Connecting Streams Together

pipe() is the Most Important Stream Method
const fs = require("fs");
const zlib = require("zlib");
const crypto = require("crypto");

// Simple copy: read from one file, write to another
fs.createReadStream("./input.txt")
  .pipe(fs.createWriteStream("./copy.txt"));

// Compress a file: read -> gzip -> write
fs.createReadStream("./bigfile.log")
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("./bigfile.log.gz"));

// Chain multiple transforms: read -> encrypt -> compress -> write
fs.createReadStream("./sensitive.json")
  .pipe(crypto.createCipheriv("aes-256-cbc", key, iv))
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("./sensitive.json.enc.gz"));

// Modern: pipeline() with error handling (use this over pipe)
const { pipeline } = require("stream/promises");

async function compressFile(input, output) {
  await pipeline(
    fs.createReadStream(input),
    zlib.createGzip(),
    fs.createWriteStream(output)
  );
  console.log("Compression complete");
  // pipeline automatically handles errors and cleanup
}

// HTTP streaming -- serve large files without buffering
app.get("/download/:filename", (req, res) => {
  const filePath = path.join("./files", req.params.filename);
  const stat = fs.statSync(filePath);

  res.writeHead(200, {
    "Content-Type": "application/octet-stream",
    "Content-Length": stat.size,
    "Content-Disposition": `attachment; filename="${req.params.filename}"`,
  });

  // Stream the file to the client -- constant memory usage regardless of file size
  fs.createReadStream(filePath).pipe(res);
});

Transform Streams -- Processing Data as It Flows

Building Custom Transform Streams
const { Transform } = require("stream");

// Transform that converts text to uppercase
const upperCase = new Transform({
  transform(chunk, encoding, callback) {
    // chunk is a Buffer, convert to string, transform, push out
    this.push(chunk.toString().toUpperCase());
    callback();  // Signal that we're done processing this chunk
  }
});

// Use it in a pipeline
fs.createReadStream("./input.txt")
  .pipe(upperCase)
  .pipe(fs.createWriteStream("./UPPER_OUTPUT.txt"));

// Transform that filters lines (e.g., only errors from a log)
const errorFilter = new Transform({
  objectMode: false,
  transform(chunk, encoding, callback) {
    const lines = chunk.toString().split("\n");
    const errors = lines.filter(line => line.includes("ERROR"));
    if (errors.length > 0) {
      this.push(errors.join("\n") + "\n");
    }
    callback();
  }
});

// Real-world: CSV parser as a transform stream
const csvParser = new Transform({
  objectMode: true,  // Push JS objects instead of buffers
  transform(chunk, encoding, callback) {
    const lines = chunk.toString().split("\n");
    for (const line of lines) {
      if (line.trim()) {
        const [id, name, email] = line.split(",");
        this.push({ id, name, email });  // Push JS object
      }
    }
    callback();
  }
});
Backpressure -- The #1 Stream Concept Developers Miss

Backpressure happens when a writable stream can't keep up with a readable stream. If the reader produces data faster than the writer can consume it, data buffers in memory and eventually crashes your process.

// BAD: ignoring backpressure
readable.on("data", (chunk) => {
  writable.write(chunk);  // What if writable is slow? Memory grows unbounded!
});

// GOOD: pipe() handles backpressure automatically
readable.pipe(writable);  // Pauses readable when writable's buffer is full

// GOOD: manual backpressure handling
readable.on("data", (chunk) => {
  const ok = writable.write(chunk);
  if (!ok) {
    readable.pause();  // Stop reading until writer catches up
    writable.once("drain", () => {
      readable.resume();  // Writer caught up, resume reading
    });
  }
});

// BEST: use pipeline (handles backpressure + errors + cleanup)
const { pipeline } = require("stream/promises");
await pipeline(readable, transform, writable);
Streams Cheat Sheet
  • Small file (<50MB): readFile/writeFile is fine
  • Large file (>50MB): Always use streams
  • File download: createReadStream(file).pipe(res)
  • File upload: req.pipe(createWriteStream(dest))
  • Compression: pipeline(input, zlib.createGzip(), output)
  • Line-by-line: Use readline.createInterface({ input: stream })
  • Error handling: Always use pipeline() over .pipe()

5. Database Essentials: Transactions, Indexes & Scale

Your app is only as good as its data layer. Understanding how databases actually work -- not just writing queries, but understanding transactions, indexes, connection pooling, and scaling patterns -- separates junior developers from engineers who can build systems that survive production traffic.

ACID -- The Four Guarantees of Reliable Databases

ACID is not academic theory. It's the set of promises your database makes so your data doesn't get corrupted. Every time you transfer money, place an order, or update a user profile, ACID is protecting you.

ACID Properties Explained with Real Scenarios
// A = ATOMICITY -- "All or nothing"
// A bank transfer: debit Account A AND credit Account B
// If the server crashes after debiting A but before crediting B,
// the ENTIRE transaction rolls back. Money doesn't vanish.

BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
  -- Server crashes here? Both statements roll back. No money lost.
  UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;

// C = CONSISTENCY -- "Rules are never broken"
// If you have a constraint "balance >= 0", the database will
// reject any transaction that would make balance negative.
// The database moves from one valid state to another valid state.

// I = ISOLATION -- "Concurrent transactions don't interfere"
// User A reads their balance (1000) while User B transfers money to them.
// User A sees either the old balance or the new one -- never a
// half-updated state. (The isolation LEVEL determines the exact behavior.)

// D = DURABILITY -- "Committed data survives crashes"
// Once the database says "COMMIT successful", the data is on disk.
// Even if the server loses power 1ms later, the data is safe.
ACID Properties (Formal Definitions):

Atomicity: All operations in a transaction succeed, or none do. No partial commits.
Consistency: A transaction brings the database from one valid state to another. All constraints are satisfied.
Isolation: Concurrent transactions appear as if they ran sequentially. One transaction cannot see another's uncommitted changes.
Durability: Once committed, the data survives crashes, power loss, and restarts (written to disk/WAL).

Transactions in Practice

Node.js Transaction Patterns
// PostgreSQL with pg (node-postgres)
const { Pool } = require("pg");
const pool = new Pool();

async function transferMoney(fromId, toId, amount) {
  const client = await pool.connect();  // Get a connection from the pool

  try {
    await client.query("BEGIN");

    // Lock the rows we're modifying (SELECT ... FOR UPDATE)
    const { rows } = await client.query(
      "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
      [fromId]
    );

    if (rows[0].balance < amount) {
      await client.query("ROLLBACK");
      throw new Error("Insufficient funds");
    }

    await client.query(
      "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
      [amount, fromId]
    );

    await client.query(
      "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
      [amount, toId]
    );

    await client.query("COMMIT");
    return { success: true };

  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();  // ALWAYS return connection to pool
  }
}

// Prisma ORM transaction
const result = await prisma.$transaction(async (tx) => {
  const sender = await tx.account.update({
    where: { id: fromId },
    data: { balance: { decrement: amount } },
  });

  if (sender.balance < 0) {
    throw new Error("Insufficient funds");  // Auto-rollback
  }

  await tx.account.update({
    where: { id: toId },
    data: { balance: { increment: amount } },
  });

  return { success: true };
});

Transaction Isolation Levels

What Each Level Protects Against
// From weakest to strongest:

// 1. READ UNCOMMITTED (almost never used)
//    Can see uncommitted changes from other transactions ("dirty reads")
//    Problem: You read data that might get rolled back

// 2. READ COMMITTED (PostgreSQL default)
//    Only sees committed data. No dirty reads.
//    Problem: If you read the same row twice in one transaction,
//    you might get different values ("non-repeatable read")

// 3. REPEATABLE READ (MySQL InnoDB default)
//    Reading the same row twice always gives the same result
//    Problem: New rows inserted by other transactions might
//    appear in range queries ("phantom reads")

// 4. SERIALIZABLE (strongest, slowest)
//    Transactions behave as if they ran one at a time
//    No anomalies, but highest overhead and most lock contention

-- Set isolation level per transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  -- your queries here
COMMIT;

-- The right level depends on your use case:
-- Financial transfers:  SERIALIZABLE or REPEATABLE READ
-- Analytics queries:    READ COMMITTED (acceptable stale data)
-- Inventory deduction:  SERIALIZABLE (prevent overselling)

Indexes -- Why Your Query is Slow

An index is a data structure (usually a B-tree) that lets the database find rows without scanning the entire table. Without an index, finding one user in 10 million rows means reading all 10 million rows. With an index, it takes ~23 lookups (log2 of 10M).

Index Types and When to Use Each
-- B-Tree Index (default, most common)
-- Good for: equality, range queries, sorting, prefix matching
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_orders_date ON orders (created_at);

-- Now these queries use the index instead of scanning all rows:
SELECT * FROM users WHERE email = 'sean@example.com';    -- O(log n) instead of O(n)
SELECT * FROM orders WHERE created_at > '2024-01-01';    -- Range scan on index
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;  -- Index already sorted

-- Composite Index (multiple columns)
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at);

-- This index helps queries that filter by user_id, or by user_id AND created_at
-- Column ORDER MATTERS: (user_id, date) helps "WHERE user_id = 5"
-- but does NOT help "WHERE created_at > ..." alone (leftmost prefix rule)

-- Unique Index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_email ON users (email);

-- Partial Index (index only some rows -- saves space)
CREATE INDEX idx_active_users ON users (email) WHERE active = true;

-- GIN Index (for arrays, JSONB, full-text search -- PostgreSQL)
CREATE INDEX idx_tags ON articles USING GIN (tags);
SELECT * FROM articles WHERE tags @> ARRAY['javascript'];

-- Hash Index (equality only, no range queries)
CREATE INDEX idx_session_token ON sessions USING HASH (token);
Index Traps -- Common Mistakes
-- MISTAKE 1: Indexing everything
-- Each index slows down INSERT/UPDATE/DELETE because the index
-- must also be updated. More indexes = slower writes.

-- MISTAKE 2: Functions defeat indexes
SELECT * FROM users WHERE LOWER(email) = 'sean@example.com';
-- This does NOT use the index on email! The function prevents it.
-- Fix: Create an expression index:
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

-- MISTAKE 3: Leading wildcard kills index
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- Cannot use B-tree index (doesn't know where to start)
-- Fix: Use a reverse index or full-text search

-- MISTAKE 4: Wrong column order in composite index
CREATE INDEX idx ON orders (created_at, user_id);
SELECT * FROM orders WHERE user_id = 5;
-- This query CANNOT use the index efficiently because
-- user_id is the second column. Leftmost prefix rule!

-- MISTAKE 5: Not using EXPLAIN
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'sean@example.com';
-- Always check the query plan. "Seq Scan" = no index used. Bad.
-- "Index Scan" or "Index Only Scan" = index used. Good.

Connection Pooling

Opening a database connection is expensive (TCP handshake, SSL negotiation, authentication). A connection pool keeps a set of open connections ready to use. Your app borrows a connection, runs a query, and returns it to the pool.

Connection Pooling in Node.js
const { Pool } = require("pg");

// Create a pool with sensible defaults
const pool = new Pool({
  host: "localhost",
  port: 5432,
  database: "myapp",
  user: "appuser",
  password: process.env.DB_PASSWORD,
  max: 20,                // Maximum connections in pool
  idleTimeoutMillis: 30000,    // Close idle connections after 30s
  connectionTimeoutMillis: 5000, // Fail if can't connect in 5s
});

// Simple query (pool manages the connection for you)
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [userId]);

// For transactions: manually check out a connection
const client = await pool.connect();
try {
  await client.query("BEGIN");
  // ... your transaction queries ...
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
} finally {
  client.release();  // CRITICAL: always release back to pool
  // If you forget client.release(), the connection leaks.
  // After max connections leak, new requests hang forever.
}

// Monitor pool health
pool.on("error", (err) => {
  console.error("Unexpected pool error:", err);
});

// Sizing: max connections = (CPU cores * 2) + number of disks
// For most web apps, 10-20 connections handles hundreds of req/s.
// More connections = more contention, not more throughput.

N+1 Query Problem

The Most Common Performance Bug in Backend Code
// PROBLEM: Fetching users and their orders
const users = await db.query("SELECT * FROM users LIMIT 100");

// N+1: 1 query for users + 100 queries for orders = 101 queries!
for (const user of users) {
  const orders = await db.query(
    "SELECT * FROM orders WHERE user_id = $1", [user.id]
  );
  user.orders = orders;
}

// FIX 1: JOIN (single query)
const result = await db.query(`
  SELECT u.*, o.id AS order_id, o.total, o.created_at AS order_date
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
  LIMIT 100
`);

// FIX 2: Batch query (2 queries instead of 101)
const users = await db.query("SELECT * FROM users LIMIT 100");
const userIds = users.map(u => u.id);
const orders = await db.query(
  "SELECT * FROM orders WHERE user_id = ANY($1)", [userIds]
);

// Group orders by user_id
const ordersByUser = {};
for (const order of orders) {
  if (!ordersByUser[order.user_id]) ordersByUser[order.user_id] = [];
  ordersByUser[order.user_id].push(order);
}

// FIX 3: Use an ORM with eager loading
// Prisma
const users = await prisma.user.findMany({
  take: 100,
  include: { orders: true },  // Automatic JOIN or batch query
});
N+1 Query Problem (Precise Definition):

1 query to fetch N records + N queries to fetch related data for each = N+1 total queries.

Fix: Use a JOIN (1 query) or eager loading (2 queries: one for parents, one for all children with WHERE parent_id IN (...)).

Database Scaling Patterns

From Single Server to Distributed Database
// LEVEL 1: Vertical Scaling ("scale up")
// Bigger machine: more CPU, RAM, faster SSD
// Works until you hit hardware limits or budget limits
// Good for: most startups and mid-size apps

// LEVEL 2: Read Replicas
// One primary (handles writes) + multiple replicas (handle reads)
// Your app sends writes to primary, reads to replicas
// Good for: read-heavy apps (90% reads, 10% writes)

PRIMARY (writes) ──replicates──> REPLICA 1 (reads)
                 ──replicates──> REPLICA 2 (reads)
                 ──replicates──> REPLICA 3 (reads)

// Caveat: Replication lag. A write to primary may take 10-100ms
// to appear on replicas. "Read your own writes" pattern:
// After a write, read from primary (not replica) for that user.

// LEVEL 3: Sharding (horizontal partitioning)
// Split data across multiple databases by a shard key
// Example: users 1-1M on shard 1, users 1M-2M on shard 2

function getShard(userId) {
  return userId % NUM_SHARDS;  // Simple hash-based sharding
}

// Shard 0: users where id % 3 == 0
// Shard 1: users where id % 3 == 1
// Shard 2: users where id % 3 == 2

// Caveats:
// - Cross-shard queries are expensive (joining data across shards)
// - Resharding (adding more shards) is painful
// - Some queries become impossible (ORDER BY across all shards)
// - Only shard when you've exhausted other options

// LEVEL 4: Caching layer
// Redis/Memcached in front of the database
// Cache frequently-read, rarely-changed data

async function getUser(id) {
  // 1. Check cache first
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);

  // 2. Cache miss -- query database
  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);

  // 3. Store in cache for 5 minutes
  await redis.setex(`user:${id}`, 300, JSON.stringify(user));

  return user;
}

// Cache invalidation (the hard part):
// When user data changes, delete/update the cache
async function updateUser(id, data) {
  await db.query("UPDATE users SET name = $1 WHERE id = $2", [data.name, id]);
  await redis.del(`user:${id}`);  // Invalidate cache
}

Database Migrations

Changing Your Schema Safely in Production
// Migrations are versioned SQL scripts that evolve your database schema
// They run in order and track which ones have been applied

// Example: migration file 001_create_users.sql
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(100) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

// 002_add_avatar_to_users.sql
ALTER TABLE users ADD COLUMN avatar_url TEXT;

// 003_create_orders.sql
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  total DECIMAL(10, 2) NOT NULL,
  status VARCHAR(20) DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders (user_id);

// Tools: Prisma Migrate, Knex migrations, golang-migrate, Flyway
// NEVER modify a database schema by hand in production.
// ALWAYS use migrations so changes are tracked, reversible, and reproducible.

// Dangerous migrations (require careful planning):
// - Renaming columns (breaks existing queries)
// - Dropping columns (data loss, breaks existing code)
// - Adding NOT NULL columns to large tables (full table lock)
// Pattern: deploy code that handles both old AND new schema,
// then run migration, then deploy code that uses only new schema.
Database Concepts Cheat Sheet
  • ACID -- Atomicity, Consistency, Isolation, Durability. The guarantees that prevent data corruption.
  • Transaction -- A group of queries that succeed or fail together. Use for any multi-step data change.
  • Index -- Makes reads fast, makes writes slightly slower. Always index columns you WHERE, JOIN, or ORDER BY.
  • N+1 Problem -- Querying in a loop. Fix with JOINs, batch queries, or ORM eager loading.
  • Connection Pool -- Reuse database connections instead of opening/closing per query.
  • Read Replica -- Copy of database for read-only queries. Reduces load on primary.
  • Sharding -- Splitting data across multiple databases. Last resort for extreme scale.
  • Cache -- Store hot data in Redis/Memcached. Invalidation is the hard part.
  • Migration -- Versioned schema changes. Never ALTER production by hand.
  • EXPLAIN ANALYZE -- Your best friend for debugging slow queries.

6. Chunked Reading & Streaming Servers

Most tutorials teach you to read a file and send it. That works for tiny files. In production, you stream data in chunks -- keeping memory constant whether the file is 1KB or 10GB. This section explains why and how.

The Problem with Reading Entire Files

The naive approach loads the entire file into RAM before sending it to the client. This is fine for a config file. It is catastrophic for media files.

Why This Kills Production Servers

fs.readFile() loads the whole file into a single Buffer in memory. If your file is 500MB, that is 500MB of RAM per request. 100 concurrent downloads = 50GB of RAM. Your server will OOM-crash long before that.

Naive vs Streamed -- Memory Comparison
JavaScript
// BAD: Reads entire file into memory
const http = require("http");
const fs = require("fs");

http.createServer((req, res) => {
  const data = fs.readFileSync("big-video.mp4");  // 500MB in RAM!
  res.writeHead(200, { "Content-Type": "video/mp4" });
  res.end(data);
}).listen(3000);

// GOOD: Streams file in chunks (~64KB in memory at a time)
http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "video/mp4" });
  const stream = fs.createReadStream("big-video.mp4");
  stream.pipe(res);  // Chunks flow from disk to network automatically
}).listen(3000);

With streaming, memory usage stays at roughly 64KB regardless of file size. The file is read chunk by chunk from disk and each chunk is sent to the client before the next is read.

How Chunked Reading Works

Files are read in fixed-size chunks, typically 16KB to 64KB. Each chunk is sent to the client as soon as it is read. Memory usage remains constant no matter how large the file is.

The Chunked Reading Pipeline:

Disk                    Server Memory           Network              Client
┌──────────┐           ┌──────────────┐        ┌──────────┐       ┌──────────┐
│          │  read()   │              │ write() │          │       │          │
│  File    │──[64KB]──▶│    Buffer    │──[64KB]─▶│  Socket  │──────▶│ Browser  │
│  (10GB)  │           │   (~64KB)    │         │          │       │          │
│          │  repeat   │              │  repeat │          │       │          │
└──────────┘           └──────────────┘        └──────────┘       └──────────┘

Memory used: ~64KB constant          NOT 10GB!
Key Insight

The chunk size is controlled by the highWaterMark option in Node.js streams. The default is 64KB for file streams. You can tune this: smaller chunks use less memory but more syscalls; larger chunks use more memory but fewer syscalls.

HTTP Chunked Transfer Encoding

When the server does not know the total size of the response upfront (e.g., dynamically generated content), it uses Transfer-Encoding: chunked. Each chunk is framed with its size in hexadecimal, followed by CRLF, the data, and another CRLF.

Raw Chunked HTTP Response Format
Terminal
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Hello, \r\n
6\r\n
World!\r\n
0\r\n
\r\n

# Breakdown:
# "7"      = next chunk is 7 bytes (hex)
# "Hello, " = the 7 bytes of data
# "6"      = next chunk is 6 bytes
# "World!" = the 6 bytes of data
# "0"      = zero-length chunk signals end of response

This format lets the server start sending data before it knows how much there is. The client reads chunk by chunk until it sees the zero-length terminator.

When Chunked vs Content-Length

If you know the exact size (static file), use Content-Length -- it is more efficient and lets clients show progress bars. Use Transfer-Encoding: chunked when the total size is unknown: server-sent events, dynamically generated responses, or proxied streams.

Implementing a Chunked File Server in Node.js

A production file server needs to handle range requests (for video seeking), set correct headers, and stream efficiently.

File Server with Range Request Support (206 Partial Content)
JavaScript
const http = require("http");
const fs = require("fs");
const path = require("path");

http.createServer((req, res) => {
  const filePath = path.join(__dirname, "videos", "demo.mp4");
  const stat = fs.statSync(filePath);
  const fileSize = stat.size;

  const range = req.headers.range;

  if (range) {
    // Range request: "bytes=32324-"
    const parts = range.replace(/bytes=/, "").split("-");
    const start = parseInt(parts[0], 10);
    const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
    const chunkSize = end - start + 1;

    res.writeHead(206, {
      "Content-Range": `bytes ${start}-${end}/${fileSize}`,
      "Accept-Ranges": "bytes",
      "Content-Length": chunkSize,
      "Content-Type": "video/mp4",
    });

    const stream = fs.createReadStream(filePath, { start, end });
    stream.pipe(res);
  } else {
    // Full file request
    res.writeHead(200, {
      "Content-Length": fileSize,
      "Content-Type": "video/mp4",
    });

    fs.createReadStream(filePath).pipe(res);
  }
}).listen(3000, () => console.log("File server on :3000"));

When a browser plays an MP4 and the user seeks to 2:30, the browser sends a Range: bytes=15000000- header. The server responds with 206 Partial Content and streams only the requested portion.

Custom highWaterMark for Tuning
JavaScript
// Default: 64KB chunks
const stream = fs.createReadStream("large.bin");

// Custom: 256KB chunks (fewer syscalls, more memory per stream)
const stream = fs.createReadStream("large.bin", {
  highWaterMark: 256 * 1024  // 256KB
});

// Custom: 16KB chunks (less memory, more syscalls)
const stream = fs.createReadStream("large.bin", {
  highWaterMark: 16 * 1024   // 16KB
});

Chunked File Server in Go

Go's standard library handles much of this for you, but understanding the internals matters.

Go File Server -- From Simple to Custom
Go
package main

import (
    "io"
    "net/http"
    "os"
)

func main() {
    // Method 1: http.ServeFile (handles Range requests automatically)
    http.HandleFunc("/video", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "./videos/demo.mp4")
    })

    // Method 2: Manual chunk-by-chunk reading
    http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
        file, err := os.Open("./files/large.bin")
        if err != nil {
            http.Error(w, "File not found", 404)
            return
        }
        defer file.Close()

        w.Header().Set("Content-Type", "application/octet-stream")

        // io.Copy reads in 32KB chunks internally
        // It calls file.Read() repeatedly and writes to the ResponseWriter
        io.Copy(w, file)
    })

    // Method 3: Custom chunk size with a buffer
    http.HandleFunc("/custom", func(w http.ResponseWriter, r *http.Request) {
        file, err := os.Open("./files/large.bin")
        if err != nil {
            http.Error(w, "File not found", 404)
            return
        }
        defer file.Close()

        buf := make([]byte, 128*1024) // 128KB chunks
        for {
            n, err := file.Read(buf)
            if n > 0 {
                w.Write(buf[:n])
            }
            if err == io.EOF {
                break
            }
            if err != nil {
                break
            }
        }
    })

    http.ListenAndServe(":8080", nil)
}
Go vs Node.js for File Serving

http.ServeFile in Go automatically handles Range headers, If-Modified-Since, Content-Type detection, and proper caching headers. In Node.js, you must implement these yourself or use a library. For production static file serving, both languages are fast -- the bottleneck is disk I/O, not the language.

Chunked Uploads

Downloading files in chunks is only half the story. Uploading large files reliably requires chunking too -- especially over unreliable networks.

Multipart Upload Strategy
JavaScript
// Client-side: Split a large file into chunks and upload each
async function uploadInChunks(file, chunkSize = 5 * 1024 * 1024) {
  const totalChunks = Math.ceil(file.size / chunkSize);

  // Step 1: Initiate upload, get an upload ID
  const { uploadId } = await fetch("/api/upload/init", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      totalChunks,
      fileSize: file.size,
    }),
  }).then(r => r.json());

  // Step 2: Upload each chunk
  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append("chunk", chunk);
    formData.append("chunkIndex", i);
    formData.append("uploadId", uploadId);

    await fetch("/api/upload/chunk", {
      method: "POST",
      body: formData,
    });

    console.log(`Uploaded chunk ${i + 1}/${totalChunks}`);
  }

  // Step 3: Finalize -- server assembles chunks
  await fetch("/api/upload/complete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ uploadId }),
  });
}
How Cloud Storage Handles This
  • AWS S3 Multipart Upload: Files over 100MB should use multipart. You initiate, upload parts (min 5MB each), then complete. S3 assembles them server-side.
  • Resumable Uploads: If a chunk fails, you retry only that chunk -- not the entire file. The server tracks which chunks have been received.
  • Why 5MB minimum? S3 requires each part to be at least 5MB (except the last). This prevents excessive overhead from managing millions of tiny parts.

7. Scalability Patterns

Your app works on localhost. Then real users show up. This section covers the patterns that take you from "works on my machine" to "handles millions of requests."

Vertical vs Horizontal Scaling

There are exactly two ways to get more capacity: make the server bigger, or add more servers.

Vertical vs Horizontal Scaling:

VERTICAL SCALING                    HORIZONTAL SCALING
(Scale Up)                          (Scale Out)

┌─────────────────┐                ┌─────────┐ ┌─────────┐ ┌─────────┐
│                 │                │ Server  │ │ Server  │ │ Server  │
│   BIG SERVER    │                │   1     │ │   2     │ │   3     │
│                 │                └────┬────┘ └────┬────┘ └────┬────┘
│  64 CPU cores   │                     │           │           │
│  512 GB RAM     │                     └─────┬─────┘───────────┘
│  10TB SSD       │                           │
│                 │                    ┌──────┴──────┐
└─────────────────┘                    │ Load Balancer│
                                       └─────────────┘
Pro: Simple, no code changes           Pro: No single point of failure
Con: Has a ceiling, expensive          Con: Requires stateless design
Con: Single point of failure           Con: More operational complexity
When to Use Which
  • Start vertical. It is simpler. A single beefy server can handle more traffic than most startups will ever see.
  • Go horizontal when: You need fault tolerance (server dies, others keep running), you hit the ceiling of the biggest machine available, or your workload is embarrassingly parallel.
  • Most apps do both: Vertically scale each node to a reasonable size, then horizontally scale the number of nodes.

Stateless Services

The golden rule of horizontal scaling: any server must be able to handle any request. This means no request-specific state stored in the server's memory.

The Stateful Trap

If you store user sessions in memory (const sessions = {}), you cannot add more servers. User A logs in on Server 1, but their next request hits Server 2 -- which has no idea who they are. This is why you must externalize state.

Stateful vs Stateless Authentication
JavaScript
// STATEFUL: Sessions stored in server memory (breaks with multiple servers)
const sessions = {};

app.post("/login", (req, res) => {
  const sessionId = crypto.randomUUID();
  sessions[sessionId] = { userId: user.id, email: user.email };
  res.cookie("sessionId", sessionId);
  res.json({ success: true });
});

app.get("/profile", (req, res) => {
  const session = sessions[req.cookies.sessionId]; // Only works on THIS server!
  if (!session) return res.status(401).json({ error: "Not authenticated" });
  res.json(session);
});

// STATELESS: JWT (works with any number of servers)
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET;

app.post("/login", (req, res) => {
  const token = jwt.sign({ userId: user.id, email: user.email }, SECRET, {
    expiresIn: "24h",
  });
  res.json({ token });
});

app.get("/profile", (req, res) => {
  const token = req.headers.authorization?.split(" ")[1];
  try {
    const payload = jwt.verify(token, SECRET); // Any server can verify this
    res.json(payload);
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
});

With JWTs, the token itself contains the user's data, signed by the server. Any server with the secret key can verify it -- no shared state needed.

Where to Put State Instead
  • Sessions: Redis (fast, shared across all servers)
  • File uploads: S3 or object storage (not local disk)
  • Cache: Redis or Memcached (shared cache layer)
  • Database: PostgreSQL, MySQL, MongoDB (already external)

Load Balancing

A load balancer distributes incoming requests across multiple servers. It is the entry point for all traffic in a horizontally scaled system.

Load Balancing Algorithms
Terminal
# Round-Robin: Requests go to servers in order (1, 2, 3, 1, 2, 3...)
# Simple and fair. Default for most load balancers.
Request 1 → Server A
Request 2 → Server B
Request 3 → Server C
Request 4 → Server A  (back to start)

# Least Connections: Send to the server with fewest active connections.
# Better when requests have variable processing time.
Server A (3 active) ← skip
Server B (1 active) ← SEND HERE
Server C (2 active) ← skip

# IP Hash: Hash the client IP to always route them to the same server.
# Useful for session affinity without shared session stores.
hash("192.168.1.1") % 3 = 1 → always Server B

# Weighted: Servers with more capacity get more traffic.
# Server A (weight 5): gets 50% of requests
# Server B (weight 3): gets 30% of requests
# Server C (weight 2): gets 20% of requests
L4 (Transport) vs L7 (Application) Load Balancing:

L4 Load Balancer: Works at the TCP level. Sees IP addresses and ports. Very fast (just forwards packets). Cannot inspect HTTP headers, URLs, or cookies.

L7 Load Balancer: Works at the HTTP level. Can route based on URL path, headers, cookies. Can terminate TLS, add headers, rewrite URLs. Slower but much more flexible.

Example: L7 can route /api/* to backend servers and /static/* to a CDN. L4 cannot -- it sees only the destination port.
Sticky Sessions Are a Code Smell

Sticky sessions (always routing the same user to the same server) defeat the purpose of load balancing. If that server dies, the user loses their session. If it gets overloaded, you cannot redistribute. Fix the root cause: make your services stateless.

Caching Layers

The fastest request is one you never make. Caching stores computed results so they can be served instantly on subsequent requests.

The Cache Hierarchy (request flows left to right, first hit wins):

Browser     CDN        Reverse       Application    Database
Cache       Cache      Proxy Cache   Cache (Redis)  Query Cache
┌─────┐   ┌─────┐    ┌─────────┐   ┌───────────┐  ┌──────────┐
│ HIT │──▶│ HIT │──▶ │  HIT    │──▶│   HIT     │──▶│  Query   │
│     │   │     │    │ (Nginx) │   │  (Redis)  │  │  (MySQL) │
└──┬──┘   └──┬──┘    └────┬────┘   └─────┬─────┘  └──────────┘
   │         │            │              │
 ~0ms      ~5ms        ~10ms          ~1ms           ~50-500ms

The closer to the user, the faster. But invalidation gets harder.
Cache-Aside Pattern with Redis
JavaScript
const Redis = require("ioredis");
const redis = new Redis();

async function getUserById(userId) {
  const cacheKey = `user:${userId}`;

  // Step 1: Check cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log("Cache HIT");
    return JSON.parse(cached);
  }

  // Step 2: Cache miss -- query database
  console.log("Cache MISS");
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);

  // Step 3: Store in cache for next time (expire after 5 minutes)
  await redis.set(cacheKey, JSON.stringify(user), "EX", 300);

  return user;
}

// When user data changes, invalidate the cache
async function updateUser(userId, data) {
  await db.query("UPDATE users SET name = $1 WHERE id = $2", [data.name, userId]);
  await redis.del(`user:${userId}`);  // Invalidate cache
}
Cache Invalidation Strategies
  • TTL (Time-To-Live): Cache expires after N seconds. Simple, but data can be stale for up to N seconds.
  • Event-Based: When data changes, explicitly delete the cache entry. Precise, but requires discipline.
  • Cache-Aside (Lazy): Application checks cache first, loads from DB on miss, writes to cache. Most common pattern.
  • Write-Through: Every write goes to both cache and DB. Consistent, but slower writes.

"There are only two hard things in computer science: cache invalidation and naming things." -- Phil Karlton

Database Scaling

The database is almost always the bottleneck. Here are the main strategies for scaling it.

Database Scaling Strategies:

READ REPLICAS                          SHARDING
(Scale reads)                          (Scale reads AND writes)

   Writes ───▶ ┌──────────┐            ┌───────────┐
               │ Primary  │            │  Shard 1  │  Users A-M
               │   (RW)   │            │  (RW)     │
               └────┬─────┘            └───────────┘
          ┌─────────┼──────────┐
          ▼         ▼          ▼        ┌───────────┐
     ┌────────┐ ┌────────┐ ┌────────┐  │  Shard 2  │  Users N-Z
     │Replica │ │Replica │ │Replica │  │  (RW)     │
     │ (RO)   │ │ (RO)   │ │ (RO)   │  └───────────┘
     └────────┘ └────────┘ └────────┘
          ▲         ▲          ▲        Cross-shard queries
     Reads distributed across replicas  are painful. Avoid if possible.
Connection Pooling -- Why It Matters
JavaScript
const { Pool } = require("pg");

// WITHOUT pooling: new connection per query (~50-100ms overhead each)
// WITH pooling: reuse connections from a pool (~0ms overhead)

const pool = new Pool({
  host: "localhost",
  database: "myapp",
  max: 20,           // Maximum 20 connections in the pool
  idleTimeoutMillis: 30000,  // Close idle connections after 30s
  connectionTimeoutMillis: 2000,  // Fail if can't connect in 2s
});

// Every query borrows a connection, uses it, and returns it
const result = await pool.query("SELECT * FROM users WHERE id = $1", [userId]);

// The connection is NOT closed -- it goes back to the pool for reuse

A database server can typically handle 100-500 concurrent connections. Without pooling, a busy Node.js app can exhaust connections in seconds. Pooling keeps the count bounded and reuses existing connections.

SQL vs NoSQL -- A Simple Decision Framework
  • Use SQL (PostgreSQL, MySQL) when: You have structured data with relationships, need transactions (ACID), need complex queries with JOINs, or need strong consistency.
  • Use NoSQL (MongoDB, DynamoDB) when: Your data is document-shaped (nested JSON), you need flexible schemas that change often, you need extreme horizontal scale for simple key-value lookups, or you have no cross-document relationships.
  • Default choice: Start with PostgreSQL. It handles JSON, full-text search, and most "NoSQL" use cases too.

Message Queues for Async Processing

Not everything needs to happen during the HTTP request. Heavy or slow operations should be pushed to a queue and processed in the background.

Why Queues -- The Email Example
JavaScript
// WITHOUT a queue: user waits for email to send (~2-5 seconds)
app.post("/signup", async (req, res) => {
  const user = await db.createUser(req.body);
  await sendWelcomeEmail(user.email);  // SLOW! User waits for this
  await sendSlackNotification(user);    // Even slower!
  res.json({ success: true });          // 5 seconds later...
});

// WITH a queue: user gets instant response, email sends in background
app.post("/signup", async (req, res) => {
  const user = await db.createUser(req.body);
  await queue.publish("user.created", { userId: user.id, email: user.email });
  res.json({ success: true });  // Instant! (~50ms)
});

// Separate worker process picks up the job
queue.subscribe("user.created", async (message) => {
  await sendWelcomeEmail(message.email);
  await sendSlackNotification(message);
  // If this fails, the queue retries automatically
});
Common Queue Use Cases
  • Email / Notifications: Send asynchronously, retry on failure
  • Image / Video Processing: Resize, transcode in background workers
  • Data Pipelines: ETL jobs, analytics event processing
  • Order Processing: Payment, inventory, shipping as separate steps

Popular options: RabbitMQ (full-featured), Redis Streams (simple, already have Redis), AWS SQS (managed, serverless), Kafka (high-throughput, event streaming).

Rate Limiting

Rate limiting protects your server from being overwhelmed -- whether by a traffic spike, a misbehaving client, or a deliberate attack. It is both a scalability and security tool.

Token Bucket Algorithm:

Bucket capacity: 10 tokens
Refill rate: 1 token per second

Time 0s:  [● ● ● ● ● ● ● ● ● ●]  10 tokens (full)
          Request! → consume 1 token → ALLOWED

Time 0s:  [● ● ● ● ● ● ● ● ●  ]  9 tokens
          Request! → consume 1 token → ALLOWED

          ... 8 more requests in rapid succession ...

Time 1s:  [                       ]  0 tokens (empty)
          Request! → no tokens → REJECTED (429 Too Many Requests)

Time 2s:  [●                      ]  1 token (refilled)
          Request! → consume 1 token → ALLOWED

Key insight: Allows bursts (up to bucket size) but enforces
an average rate (refill rate) over time.
Rate Limiter in Node.js
JavaScript
// Simple in-memory rate limiter using sliding window
const rateLimits = new Map();

function rateLimit(key, maxRequests, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;

  if (!rateLimits.has(key)) {
    rateLimits.set(key, []);
  }

  const timestamps = rateLimits.get(key);

  // Remove expired timestamps
  while (timestamps.length > 0 && timestamps[0] < windowStart) {
    timestamps.shift();
  }

  if (timestamps.length >= maxRequests) {
    return false;  // Rate limited
  }

  timestamps.push(now);
  return true;  // Allowed
}

// Express middleware
app.use((req, res, next) => {
  const key = req.ip;
  const allowed = rateLimit(key, 100, 60 * 1000);  // 100 requests per minute

  if (!allowed) {
    return res.status(429).json({
      error: "Too many requests",
      retryAfter: "60 seconds",
    });
  }

  next();
});
Rate Limiter in Go
Go
package main

import (
    "net/http"
    "sync"
    "time"
)

type RateLimiter struct {
    mu       sync.Mutex
    tokens   float64
    maxTokens float64
    refillRate float64   // tokens per second
    lastTime time.Time
}

func NewRateLimiter(maxTokens, refillRate float64) *RateLimiter {
    return &RateLimiter{
        tokens:     maxTokens,
        maxTokens:  maxTokens,
        refillRate: refillRate,
        lastTime:   time.Now(),
    }
}

func (rl *RateLimiter) Allow() bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(rl.lastTime).Seconds()
    rl.tokens += elapsed * rl.refillRate
    if rl.tokens > rl.maxTokens {
        rl.tokens = rl.maxTokens
    }
    rl.lastTime = now

    if rl.tokens < 1 {
        return false
    }
    rl.tokens--
    return true
}

func main() {
    limiter := NewRateLimiter(10, 1) // 10 burst, 1/sec refill

    http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }
        w.Write([]byte("OK"))
    })

    http.ListenAndServe(":8080", nil)
}
In-Memory Rate Limiting Does Not Scale

The examples above store rate limit state in the server's memory. With multiple servers, each has its own counter -- a user could send 100 requests to each of 5 servers (500 total). For production, use Redis with atomic operations (INCR + EXPIRE) so all servers share one counter per key.

8. Backend Design Patterns

These are the recurring solutions to common backend problems. Learn them and you will recognize (and solve) architectural issues faster than engineers with twice your experience.

Repository Pattern

Separate data access from business logic. A repository is an abstraction layer between your application code and the database. Your business logic asks the repository for data -- it never knows or cares whether the data comes from PostgreSQL, MongoDB, or a test mock.

Repository Pattern in Node.js (TypeScript-style)
JavaScript
// repository/userRepository.js -- defines the contract
class UserRepository {
  async findById(id) { throw new Error("Not implemented"); }
  async findByEmail(email) { throw new Error("Not implemented"); }
  async create(userData) { throw new Error("Not implemented"); }
  async update(id, userData) { throw new Error("Not implemented"); }
  async delete(id) { throw new Error("Not implemented"); }
}

// repository/pgUserRepository.js -- PostgreSQL implementation
class PgUserRepository extends UserRepository {
  constructor(pool) {
    super();
    this.pool = pool;
  }

  async findById(id) {
    const { rows } = await this.pool.query(
      "SELECT * FROM users WHERE id = $1", [id]
    );
    return rows[0] || null;
  }

  async create(userData) {
    const { rows } = await this.pool.query(
      "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
      [userData.name, userData.email]
    );
    return rows[0];
  }

  // ... update, delete, findByEmail
}

// In tests: swap with a mock
class MockUserRepository extends UserRepository {
  constructor() {
    super();
    this.users = [];
  }

  async findById(id) {
    return this.users.find(u => u.id === id) || null;
  }

  async create(userData) {
    const user = { id: this.users.length + 1, ...userData };
    this.users.push(user);
    return user;
  }
}

// Business logic doesn't know which implementation it's using
async function getUserProfile(userRepo, userId) {
  const user = await userRepo.findById(userId);
  if (!user) throw new Error("User not found");
  return { name: user.name, email: user.email };
}
Repository Pattern in Go (Interface-driven)
Go
package repository

import "context"

// Interface -- the contract
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    FindByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int64) error
}

// PostgreSQL implementation
type pgUserRepo struct {
    db *sql.DB
}

func NewPgUserRepo(db *sql.DB) UserRepository {
    return &pgUserRepo{db: db}
}

func (r *pgUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    user := &User{}
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return user, err
}

// In tests: use a mock that satisfies the same interface
type mockUserRepo struct {
    users map[int64]*User
}

func (m *mockUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    return m.users[id], nil
}
Why the Repository Pattern Matters
  • Swap databases: Move from PostgreSQL to DynamoDB without touching business logic
  • Testing: Inject a mock repository -- no database needed in unit tests
  • Single Responsibility: Business logic handles rules, repositories handle data access

Service Layer Pattern

The service layer sits between your HTTP controllers and your data repositories. Controllers handle HTTP concerns (parsing requests, sending responses). Services handle business logic (validation, rules, orchestration). Repositories handle data access.

The Three-Layer Architecture:

HTTP Request
    │
    ▼
┌──────────────┐  Parses request, validates input format,
│  Controller  │  calls service, sends HTTP response.
│  (HTTP)      │  Knows about req/res. Thin.
└──────┬───────┘
       │
       ▼
┌──────────────┐  Contains business rules, orchestrates
│   Service    │  repository calls, handles validation.
│  (Logic)     │  Knows nothing about HTTP.
└──────┬───────┘
       │
       ▼
┌──────────────┐  Talks to the database. Returns data.
│  Repository  │  Knows nothing about business rules.
│  (Data)      │
└──────────────┘
       │
       ▼
   Database
Service Layer in Practice
JavaScript
// service/userService.js -- business logic lives here
class UserService {
  constructor(userRepo, emailService) {
    this.userRepo = userRepo;
    this.emailService = emailService;
  }

  async signup(name, email, password) {
    // Business rule: check for existing user
    const existing = await this.userRepo.findByEmail(email);
    if (existing) throw new Error("Email already registered");

    // Business rule: hash password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create user via repository
    const user = await this.userRepo.create({
      name,
      email,
      password: hashedPassword,
    });

    // Side effect: send welcome email (via another service)
    await this.emailService.sendWelcome(user.email, user.name);

    return { id: user.id, name: user.name, email: user.email };
  }
}

// controller/userController.js -- HTTP concerns only
app.post("/api/signup", async (req, res) => {
  try {
    const { name, email, password } = req.body;

    if (!name || !email || !password) {
      return res.status(400).json({ error: "Missing required fields" });
    }

    const user = await userService.signup(name, email, password);
    res.status(201).json(user);
  } catch (err) {
    if (err.message === "Email already registered") {
      return res.status(409).json({ error: err.message });
    }
    res.status(500).json({ error: "Internal server error" });
  }
});

The controller is thin -- it parses the request, calls the service, and maps the result to an HTTP response. All business logic (duplicate check, password hashing, email sending) lives in the service.

Middleware Pattern

Middleware functions form a chain. Each function can process the request, modify it, and decide whether to pass it to the next function. This is how you add cross-cutting concerns (logging, auth, rate limiting) without cluttering your route handlers.

Middleware Chain in Express
JavaScript
// Each middleware: do something, then call next()
// If you don't call next(), the chain stops.

// 1. Logging middleware
function logger(req, res, next) {
  const start = Date.now();
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.url} ${res.statusCode} ${duration}ms`);
  });
  next();
}

// 2. Auth middleware
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "No token" });

  try {
    req.user = jwt.verify(token, SECRET);
    next();  // Token valid, continue to next middleware
  } catch {
    res.status(401).json({ error: "Invalid token" });
    // No next() call -- chain stops here
  }
}

// 3. Rate limiting middleware
function rateLimit(req, res, next) {
  if (!limiter.allow(req.ip)) {
    return res.status(429).json({ error: "Too many requests" });
  }
  next();
}

// Apply middleware in order: logger → rateLimit → auth → handler
app.use(logger);
app.use(rateLimit);

app.get("/api/profile", authenticate, (req, res) => {
  // Only reached if logger, rateLimit, AND authenticate all called next()
  res.json({ user: req.user });
});
Middleware Chain in Go
Go
package main

import (
    "log"
    "net/http"
    "time"
)

// Middleware type: takes a handler, returns a handler
type Middleware func(http.Handler) http.Handler

// Logging middleware
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// Auth middleware
func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", 401)
            return // Chain stops -- next is NOT called
        }
        // Validate token, set user in context...
        next.ServeHTTP(w, r)
    })
}

// Chain middleware: Logger → Auth → Handler
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        handler = middlewares[i](handler)
    }
    return handler
}

func main() {
    api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, authenticated user!"))
    })

    http.Handle("/api/profile", Chain(api, Logger, Auth))
    http.ListenAndServe(":8080", nil)
}

Circuit Breaker

When a downstream service (database, external API, microservice) goes down, every request to it will hang or fail. Without a circuit breaker, your entire service grinds to a halt waiting for timeouts.

Circuit Breaker State Machine:

                    N failures
   ┌──────────┐    exceeded     ┌──────────┐
   │  CLOSED  │────────────────▶│   OPEN   │
   │ (normal) │                 │(fail fast)│
   └──────────┘                 └─────┬────┘
        ▲                             │
        │  Success                    │ After timeout
        │                             ▼
        │                       ┌───────────┐
        └───────────────────────│ HALF-OPEN │
              Success           │ (test one) │
                                └───────────┘
                                      │
                                      │ Failure
                                      ▼
                                ┌──────────┐
                                │   OPEN   │
                                │(fail fast)│
                                └──────────┘

CLOSED:    Normal operation. Requests pass through. Failures are counted.
OPEN:      All requests fail immediately (no network call). Fast failure.
HALF-OPEN: One test request is allowed through. Success → CLOSED. Failure → OPEN.
Circuit Breaker Implementation
JavaScript
class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
    this.state = "CLOSED";
    this.failureCount = 0;
    this.lastFailureTime = null;
  }

  async call(...args) {
    if (this.state === "OPEN") {
      // Check if enough time has passed to try again
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = "HALF-OPEN";
      } else {
        throw new Error("Circuit is OPEN -- failing fast");
      }
    }

    try {
      const result = await this.fn(...args);
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = "CLOSED";
  }

  onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.failureThreshold) {
      this.state = "OPEN";
    }
  }
}

// Usage
const paymentBreaker = new CircuitBreaker(
  (orderId) => fetch(`https://payments.api/charge/${orderId}`),
  { failureThreshold: 3, resetTimeout: 10000 }
);

app.post("/checkout", async (req, res) => {
  try {
    const result = await paymentBreaker.call(req.body.orderId);
    res.json(result);
  } catch (err) {
    if (err.message.includes("OPEN")) {
      res.status(503).json({ error: "Payment service unavailable, try later" });
    } else {
      res.status(500).json({ error: "Payment failed" });
    }
  }
});

Retry with Exponential Backoff

When a request fails, do not retry immediately. Wait a little, then try again. If it fails again, wait longer. This prevents a thundering herd of retries from overwhelming a recovering service.

Exponential Backoff Formula:

delay = baseDelay * 2^attempt + randomJitter

Example with baseDelay = 1 second:
Attempt 1: 1s + jitter
Attempt 2: 2s + jitter
Attempt 3: 4s + jitter
Attempt 4: 8s + jitter
Attempt 5: 16s + jitter (give up after this)

Why jitter? Without it, if 1000 clients all fail at the same time, they all retry at 1s, then 2s, then 4s -- creating synchronized bursts. Random jitter spreads retries out over time.
Retry with Exponential Backoff
JavaScript
async function retryWithBackoff(fn, options = {}) {
  const maxRetries = options.maxRetries || 5;
  const baseDelay = options.baseDelay || 1000;
  const maxDelay = options.maxDelay || 30000;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;  // Last attempt, give up

      const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
      const jitter = Math.random() * delay * 0.1;  // 10% jitter
      const totalDelay = delay + jitter;

      console.log(`Attempt ${attempt + 1} failed. Retrying in ${totalDelay}ms...`);
      await new Promise(resolve => setTimeout(resolve, totalDelay));
    }
  }
}

// Usage
const data = await retryWithBackoff(
  () => fetch("https://api.example.com/data").then(r => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.json();
  }),
  { maxRetries: 3, baseDelay: 1000 }
);
Retry with Exponential Backoff in Go
Go
package main

import (
    "fmt"
    "math"
    "math/rand"
    "time"
)

func RetryWithBackoff(fn func() error, maxRetries int, baseDelay time.Duration) error {
    for attempt := 0; attempt < maxRetries; attempt++ {
        err := fn()
        if err == nil {
            return nil
        }

        if attempt == maxRetries-1 {
            return fmt.Errorf("all %d attempts failed: %w", maxRetries, err)
        }

        delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt)))
        jitter := time.Duration(rand.Float64() * float64(delay) * 0.1)
        totalDelay := delay + jitter

        fmt.Printf("Attempt %d failed. Retrying in %v...\n", attempt+1, totalDelay)
        time.Sleep(totalDelay)
    }
    return nil
}

Event-Driven Architecture

Instead of services calling each other directly, they emit events. Other services listen for events they care about. This decouples services -- the emitter does not know or care who is listening.

Event-Driven with Node.js EventEmitter
JavaScript
const EventEmitter = require("events");
const bus = new EventEmitter();

// Service A: User signup (emits an event, doesn't know who listens)
async function signup(name, email, password) {
  const user = await db.createUser({ name, email, password });
  bus.emit("user.created", { userId: user.id, email, name });
  return user;
}

// Service B: Email service (listens for user.created)
bus.on("user.created", async ({ email, name }) => {
  await sendWelcomeEmail(email, name);
  console.log(`Welcome email sent to ${email}`);
});

// Service C: Analytics (also listens for user.created)
bus.on("user.created", async ({ userId }) => {
  await analytics.track("signup", { userId });
});

// Service D: Audit log (listens for everything)
bus.on("user.created", async (data) => {
  await auditLog.write("user.created", data);
});

// Adding a new listener requires ZERO changes to the signup function.
// That's the power of event-driven architecture.
When to Use Event-Driven Architecture
  • Decoupled microservices: Services communicate through events, not direct HTTP calls
  • Audit logs: Record every significant action without cluttering business logic
  • Notifications: Email, SMS, push -- triggered by events, not inline code
  • Eventual consistency: When immediate consistency is not required (e.g., search index updates)

For cross-process events, use a message broker (Redis Pub/Sub, RabbitMQ, Kafka) instead of in-process EventEmitter.

Graceful Shutdown

When your server receives a shutdown signal (deploy, scale-down, crash recovery), it should not just die. It should stop accepting new requests, wait for in-flight requests to finish, close database connections, and then exit cleanly.

Graceful Shutdown in Node.js
JavaScript
const http = require("http");

const server = http.createServer(app);
const connections = new Set();

server.on("connection", (conn) => {
  connections.add(conn);
  conn.on("close", () => connections.delete(conn));
});

function gracefulShutdown(signal) {
  console.log(`\n${signal} received. Starting graceful shutdown...`);

  // Step 1: Stop accepting new connections
  server.close(() => {
    console.log("All connections closed. Exiting.");
    process.exit(0);
  });

  // Step 2: Close idle keep-alive connections
  for (const conn of connections) {
    conn.end();
  }

  // Step 3: Force exit after 30 seconds if shutdown hangs
  setTimeout(() => {
    console.error("Forced shutdown after timeout.");
    process.exit(1);
  }, 30000);
}

// Catch termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));

server.listen(3000, () => console.log("Server on :3000"));
Graceful Shutdown in Go
Go
package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // Simulate work
        w.Write([]byte("Hello!"))
    })

    server := &http.Server{Addr: ":8080", Handler: mux}

    // Start server in a goroutine
    go func() {
        fmt.Println("Server listening on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            fmt.Printf("Server error: %v\n", err)
        }
    }()

    // Wait for shutdown signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    sig := <-quit
    fmt.Printf("\n%v received. Shutting down gracefully...\n", sig)

    // Give in-flight requests 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        fmt.Printf("Forced shutdown: %v\n", err)
    }

    fmt.Println("Server exited cleanly.")
}
Why Graceful Shutdown Matters in Production

Without graceful shutdown, deploying a new version kills in-flight requests. Users see errors. Database transactions are left incomplete. WebSocket connections drop with no explanation. Kubernetes sends SIGTERM before killing a pod -- your app must handle it.

Health Check & Readiness Endpoints

Load balancers and orchestrators (Kubernetes) need to know: Is this server alive? Can it handle traffic? These two questions have different answers.

Health and Readiness in Node.js
JavaScript
let isReady = false;

// /health -- Is the process alive? (liveness probe)
// If this fails, Kubernetes restarts the pod.
app.get("/health", (req, res) => {
  res.status(200).json({ status: "alive" });
});

// /ready -- Can it serve traffic? (readiness probe)
// If this fails, Kubernetes stops sending traffic to this pod.
app.get("/ready", async (req, res) => {
  try {
    // Check database connection
    await pool.query("SELECT 1");

    // Check Redis connection
    await redis.ping();

    // Check any other critical dependencies
    res.status(200).json({
      status: "ready",
      checks: {
        database: "connected",
        redis: "connected",
      },
    });
  } catch (err) {
    res.status(503).json({
      status: "not ready",
      error: err.message,
    });
  }
});

// Mark as ready after initialization is complete
async function startServer() {
  await pool.connect();
  await redis.connect();
  isReady = true;
  console.log("Server is ready to accept traffic");
}
Health and Readiness in Go
Go
package main

import (
    "encoding/json"
    "net/http"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "alive",
    })
}

func readyHandler(db *sql.DB, redisClient *redis.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")

        // Check database
        if err := db.Ping(); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            json.NewEncoder(w).Encode(map[string]string{
                "status": "not ready",
                "error":  "database: " + err.Error(),
            })
            return
        }

        // Check Redis
        if err := redisClient.Ping(r.Context()).Err(); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            json.NewEncoder(w).Encode(map[string]string{
                "status": "not ready",
                "error":  "redis: " + err.Error(),
            })
            return
        }

        json.NewEncoder(w).Encode(map[string]string{
            "status": "ready",
        })
    }
}

func main() {
    http.HandleFunc("/health", healthHandler)
    http.HandleFunc("/ready", readyHandler(db, redisClient))
    http.ListenAndServe(":8080", nil)
}
Liveness vs Readiness -- The Kubernetes Connection
  • Liveness probe (/health): "Is the process alive?" If this fails repeatedly, Kubernetes restarts the pod. Keep this check lightweight -- just return 200.
  • Readiness probe (/ready): "Can it handle traffic?" If this fails, Kubernetes removes the pod from the service load balancer but does NOT restart it. The pod stays alive, waiting for dependencies to recover.
  • Example scenario: Your app starts, but the database is still booting. Liveness passes (process is alive), readiness fails (database not connected). Kubernetes waits, no traffic is sent. Once the database connects, readiness passes, traffic flows.