Master the Node.js internals that separate frontend developers from full-stack engineers. Blob, File API, Buffers, Streams, the filesystem, and database fundamentals -- all the concepts you need to handle files, multimedia, and data at scale.
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.
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.
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.
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.
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 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
<input type="file">, you get a File object (which extends Blob)URL.createObjectURL(blob) creates a temporary URL to trigger downloadscanvas.toBlob() gives you the image as a Blob for uploadingresponse.blob() gets binary data from API responsesThe 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.
// 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 (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.
// 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
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 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.
// 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 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.
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).
// 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);
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.
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.
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 });
});
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.
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.
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)
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!
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"
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"
}
ENOENT -- File or directory not found (most common)EACCES -- Permission denied (check file ownership)EISDIR -- Expected a file but got a directoryENOTDIR -- Expected a directory but got a fileEEXIST -- 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)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 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.
// 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.
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
// 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
fs.readFile(path) without encoding returns a Buffercrypto.randomBytes(32) returns a Buffer (for tokens, salts, IVs)crypto.createHash("sha256").update(buf).digest() works with BuffersStreams 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.
Imagine 100 users simultaneously uploading 100MB files.
readFile() loads each entire file into memory. 100 users x 100MB = 10GB RAM. Your server crashes.Streams are not an optimization. They are how you build software that doesn't fall over under load.
// 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();
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);
}
}
}
}
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");
});
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);
});
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 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);
readFile/writeFile is finecreateReadStream(file).pipe(res)req.pipe(createWriteStream(dest))pipeline(input, zlib.createGzip(), output)readline.createInterface({ input: stream })pipeline() over .pipe()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 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.
// 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.
// 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 };
});
// 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)
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).
-- 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);
-- 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.
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.
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.
// 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
});
// 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
}
// 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.
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 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.
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.
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.
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.
Disk Server Memory Network Client
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ │ read() │ │ write() │ │ │ │
│ File │──[64KB]──▶│ Buffer │──[64KB]─▶│ Socket │──────▶│ Browser │
│ (10GB) │ │ (~64KB) │ │ │ │ │
│ │ repeat │ │ repeat │ │ │ │
└──────────┘ └──────────────┘ └──────────┘ └──────────┘
Memory used: ~64KB constant NOT 10GB!
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.
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.
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.
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.
A production file server needs to handle range requests (for video seeking), set correct headers, and stream efficiently.
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.
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
});
Go's standard library handles much of this for you, but understanding the internals matters.
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)
}
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.
Downloading files in chunks is only half the story. Uploading large files reliably requires chunking too -- especially over unreliable networks.
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 }),
});
}
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."
There are exactly two ways to get more capacity: make the server bigger, or add more servers.
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
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.
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.
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.
A load balancer distributes incoming requests across multiple servers. It is the entry point for all traffic in a horizontally scaled system.
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
/api/* to backend servers and /static/* to a CDN. L4 cannot -- it sees only the destination port.
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.
The fastest request is one you never make. Caching stores computed results so they can be served instantly on subsequent requests.
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.
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
}
"There are only two hard things in computer science: cache invalidation and naming things." -- Phil Karlton
The database is almost always the bottleneck. Here are the main strategies for scaling it.
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.
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.
Not everything needs to happen during the HTTP request. Heavy or slow operations should be pushed to a queue and processed in the background.
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
});
Popular options: RabbitMQ (full-featured), Redis Streams (simple, already have Redis), AWS SQS (managed, serverless), Kafka (high-throughput, event streaming).
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.
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.
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();
});
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)
}
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.
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.
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.
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 };
}
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
}
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.
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
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 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.
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 });
});
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)
}
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.
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.
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" });
}
}
});
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.
delay = baseDelay * 2^attempt + randomJitterJavaScript
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 }
);
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
}
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.
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.
For cross-process events, use a message broker (Redis Pub/Sub, RabbitMQ, Kafka) instead of in-process EventEmitter.
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.
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"));
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.")
}
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.
Load balancers and orchestrators (Kubernetes) need to know: Is this server alive? Can it handle traffic? These two questions have different answers.
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");
}
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)
}
/health): "Is the process alive?" If this fails repeatedly, Kubernetes restarts the pod. Keep this check lightweight -- just return 200./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.