Node.js NotesRohit’s interview study guide
Chapter 15

Architecture, Patterns & Databases

Designing Node systems — microservices and their failure modes, messaging, design patterns, and choosing a database.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Monoliths and microservices

A monolith is one deployable unit. Orders, payments, inventory and users all live in one codebase, run in one process (or in N identical copies behind a load balancer) and usually share one database. When the order code needs stock levels it calls a function: it takes nanoseconds, it can't half-fail, and one database transaction can cover the whole operation.

A microservice architecture splits that unit into services that are deployed independently, each built around one business capability and owning its own data. The order service asks the inventory service for stock over the network. You gain independent deploys, independent scaling and smaller ownership boundaries. You pay with everything a network brings: latency, timeouts, partial failure, retries, duplicate messages and data that is only eventually consistent.

A useful mental model is a restaurant kitchen versus a food court. In the kitchen (monolith) the chefs shout across the room, share one fridge and one opening time. In the food court (microservices) every stall has its own staff, fridge and hours. Stalls can change menus and hire staff without asking each other, but a meal from three stalls now needs a runner, any stall can be closed while the others are open, and nobody can lock all three fridges at once.

Clients web · mobile API gateway auth · routing rate limits · TLS request IDs User service owns users DB Order service owns orders DB Payment service owns payments DB Inventory service owns stock DB Message broker Kafka / RabbitMQ OrderCreated OrderPaid publish solid = synchronous request (HTTP/gRPC) · dashed = asynchronous message
Each service owns its data; the gateway is the only public entry point.

My notes say "each microservice is focused on one responsibility". The responsibility to aim for is a business capability — something with its own rules, data and team, like "payments" — not one route or one table. Services that are too small ("nanoservices") have to call each other for every request, which gives you all the costs of distribution and none of the independence.

The cost of splitting is easy to underestimate, so put numbers on it. When one request needs several services to answer synchronously, their failure and slowness compound:

chain.mjs
// A request that synchronously needs every service in the chain to succeed.
const availability = 0.999; // each service: "three nines"
for (const services of [1, 3, 5, 10]) {
  const overall = availability ** services;
  const failedPerMillion = Math.round((1 - overall) * 1_000_000);
  console.log(`${String(services).padStart(2)} services: ${(overall * 100).toFixed(2)}% available, ~${failedPerMillion} failed requests per million`);
}

// Tail latency: if each service is slow (> its p99) 1% of the time,
// how often is at least one hop in the chain slow?
for (const hops of [1, 5, 10]) {
  console.log(`${String(hops).padStart(2)} hops: ${((1 - 0.99 ** hops) * 100).toFixed(1)}% of requests hit at least one p99-slow hop`);
}

//  1 services: 99.90% available, ~1000 failed requests per million
//  3 services: 99.70% available, ~2997 failed requests per million
//  5 services: 99.50% available, ~4990 failed requests per million
// 10 services: 99.00% available, ~9955 failed requests per million
//  1 hops: 1.0% of requests hit at least one p99-slow hop
//  5 hops: 4.9% of requests hit at least one p99-slow hop
// 10 hops: 9.6% of requests hit at least one p99-slow hop
What’s happening
  1. If a request succeeds only when every service in the chain succeeds, and failures are independent, the overall success probability is the product: 0.999 ** n. In a monolith the same work is a few function calls inside one process, so there is only one thing that can be down.
  2. With one service you lose about 1,000 requests per million. With ten services in the chain, 0.999 ** 10 is 0.99004…, so you lose about 9,955 per million — ten times as many, even though every individual service is just as reliable as before.
  3. The second loop applies the same arithmetic to slowness. "p99" means 1% of calls to a service are slower than that value. A request that crosses five services is unlucky on at least one hop 1 - 0.99 ** 5 = 4.9% of the time; with ten hops it's 9.6%. Your end-to-end p99 is therefore much worse than any single service's p99 (this is often called tail-latency amplification).
  4. The numbers assume failures are independent. In real systems they are often correlated (a shared database, a bad deploy, a network partition), which can make things better or much worse — but the direction is the lesson: every synchronous hop is a new way to fail.
  5. That's why the rest of this chapter exists: timeouts, retries, circuit breakers and asynchronous messaging are the tools that stop the product from collapsing.
AdvancedCosts: monolith vs microservices

What each side costs

My notes list the "cons of monolithic architecture". Here they are next to what you pay to escape them:

MonolithMicroservices
DeploysOne deploy ships everything; a bug in reports blocks a payments fixEach service ships on its own schedule
ScalingScale the whole app to scale one hot pathScale only the service that's hot
Blast radiusA memory leak or a blocked event loop in one module takes down all of themA crash is contained to one service (if callers handle it)
TeamsEveryone works in one codebase and one release trainA team owns a service end to end
CallsFunction calls: fast, can't half-failNetwork calls: latency, timeouts, partial failure
DataOne database, real ACID transactions across everythingDatabase per service; consistency via sagas
DebuggingOne stack trace, one logNeeds correlation IDs and distributed tracing
OperationsOne thing to deploy and monitorService discovery, many pipelines, many dashboards

The middle path is a modular monolith: one deployable, but with strict internal module boundaries (each module owns its tables and exposes a small interface). It keeps function-call speed and real transactions, and if one module later needs to scale or ship independently, the boundary is already drawn and it can be extracted.

My notes' last architecture point is statelessness. Whichever shape you choose, keep each instance free of state that only it knows (in-memory sessions, local uploads, in-process caches that must be correct). Then any instance can serve the next request, and you can add, remove or restart instances freely — see Scaling to thousands of concurrent requests and Sessions vs JWTs.

Node fits microservices well — small processes, fast start-up, cheap to run many copies, and strong at I/O-heavy glue work like gateways and backend-for-frontend services — but nothing about microservices requires Node, and nothing about Node requires microservices.

Availability of a chain

A checkout request synchronously calls four services, each with 99.95% availability. Roughly how many checkouts per million fail because of them, assuming independent failures?

Show answer

About 2,000 per million (overall availability ≈ 99.80%).

What’s happening
  1. All four must succeed, so multiply their success probabilities: 0.9995 ** 4.
  2. 0.9995 ** 2 is 0.99900025; squaring again gives 0.9980015, about 99.80%.
  3. The failure rate is 1 - 0.9980015 = 0.0019985, which is about 1,998 per million — four times the single-service figure of 500.
  4. The fixes are architectural: take services that checkout doesn't need an immediate answer from off the synchronous path (publish an event instead), and give the remaining calls timeouts, retries and fallbacks.

#The API gateway

An API gateway is the single public entry point to your services. External clients talk only to the gateway; the gateway talks to the services on a private network. It takes over the cross-cutting work every service would otherwise repeat: routing by path or host, authentication (validate the token once at the edge), rate limiting, TLS termination, request IDs for tracing, CORS, and sometimes response aggregation (one client call fans out to three services and returns one combined response).

Think of it as a hotel front desk. It checks your ID once, tells you which room to go to and keeps a log of who came in. It doesn't cook your meal or clean your room — that's the job of the departments behind it. A gateway that starts to contain business rules ("if the order is over $500, call fraud…") has become a second monolith that every team has to change.

Here's a small gateway built on node:http and fetch, with two upstream services, so you can see each responsibility in code:

gateway.mjs
import http from "node:http";
import { randomUUID } from "node:crypto";

// Two tiny "services". Each only knows its own routes.
function startService(name, handler) {
  const server = http.createServer(handler);
  return new Promise((resolve) => server.listen(0, () => resolve({ server, url: `http://localhost:${server.address().port}`, name })));
}
const users = await startService("users", (req, res) => {
  res.setHeader("content-type", "application/json");
  res.end(JSON.stringify({ service: "users", path: req.url, requestId: req.headers["x-request-id"], caller: req.headers["x-user-id"] }));
});
const orders = await startService("orders", (req, res) => {
  setTimeout(() => res.end("too late"), 2_000).unref(); // a hung dependency
});

const routes = { "/users": users.url, "/orders": orders.url };

const gateway = http.createServer(async (req, res) => {
  const requestId = req.headers["x-request-id"] ?? randomUUID();
  res.setHeader("x-request-id", requestId);

  // 1. Authenticate once, at the edge.
  if (req.headers.authorization !== "Bearer valid-token") return res.writeHead(401).end("missing or bad token\n");

  // 2. Route by path prefix.
  const prefix = Object.keys(routes).find((p) => req.url === p || req.url.startsWith(p + "/"));
  if (!prefix) return res.writeHead(404).end("no such route\n");

  // 3. Forward with a deadline and the context downstream services need.
  try {
    const upstream = await fetch(routes[prefix] + req.url.slice(prefix.length), {
      method: req.method,
      headers: { "x-request-id": requestId, "x-user-id": "u-42" },
      signal: AbortSignal.timeout(500),
    });
    res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "text/plain" });
    res.end(await upstream.text());
  } catch (err) {
    res.writeHead(err.name === "TimeoutError" ? 504 : 502).end(`${prefix} unavailable: ${err.name}\n`);
  }
});
await new Promise((r) => gateway.listen(0, r));
const base = `http://localhost:${gateway.address().port}`;

const show = async (path, headers = {}) => {
  const res = await fetch(base + path, { headers });
  console.log(`${path.padEnd(10)} -> ${res.status} ${(await res.text()).trim()}`);
};
const auth = { authorization: "Bearer valid-token" };
await show("/users/7");
await show("/users/7", { ...auth, "x-request-id": "req-abc" });
await show("/orders/1", auth);
await show("/billing", auth);

for (const s of [gateway, users.server, orders.server]) s.closeAllConnections(), s.close();

// /users/7   -> 401 missing or bad token
// /users/7   -> 200 {"service":"users","path":"/7","requestId":"req-abc","caller":"u-42"}
// /orders/1  -> 504 /orders unavailable: TimeoutError
// /billing   -> 404 no such route
What’s happening
  1. startService starts two real HTTP servers on port 0, which asks the OS for any free port; server.address().port tells us which one we got. The users service echoes what it received; the orders service deliberately never answers within 2 seconds, to play a hung dependency (.unref() stops that timer keeping the process alive at the end).
  2. The first request has no Authorization header, so the gateway answers 401 itself and no service is called at all. Authentication happens once at the edge, which is why the services behind it don't each need the token-validation code (in real life this is where you'd verify a JWT — see Signing and verifying JWTs).
  3. The second request carries a token and its own x-request-id: req-abc. The gateway keeps that ID (or generates one with randomUUID() if missing), matches the /users prefix, strips it so the users service sees /7, and adds x-user-id: u-42 — the identity it established. The JSON the users service returns proves all three arrived: path: "/7", requestId: "req-abc", caller: "u-42".
  4. The third request is routed to the orders service, which hangs. AbortSignal.timeout(500) aborts the fetch after 500 ms with a TimeoutError, and the gateway turns that into 504 Gateway Timeout. Without the deadline the client's connection, the gateway's socket and its memory would be held for as long as the dependency hangs — multiply that by thousands of requests and the gateway falls over too.
  5. /billing matches no prefix, so the gateway returns 404 without touching any service. Routing knowledge lives in one place: services can move or be split and only the routes table changes.
  6. At the end, closeAllConnections() drops the keep-alive sockets fetch left open, so close() can finish and the process exits. In a real server you'd do this as part of graceful shutdown.
AdvancedOff-the-shelf gateway options

In production you'd rarely hand-roll this. Common choices are a managed gateway (AWS API Gateway, Azure API Management), a proxy such as Envoy, NGINX or Kong, or a Node gateway built with Express or Fastify plus a proxy library such as http-proxy-middleware. A Node gateway is a good fit when it also does aggregation for a particular client — a backend for frontend (BFF) that calls three services and returns exactly the shape the mobile app needs.

AdvancedPitfall: spoofed identity headers

#Synchronous vs asynchronous communication

Services talk in two basic ways. Synchronous communication means the caller sends a request and waits for the answer — HTTP/REST or gRPC. Asynchronous communication means the caller hands a message to a broker (a queue or an event log) and carries on; some consumer processes it later, and the caller may never hear back.

The mental model is a phone call versus a letter. A phone call gets you an answer now, but only if the other person picks up, and you're both stuck on the line for as long as it takes. A letter can be posted when the recipient is asleep, on holiday or busy; it waits in the postbox, and many people can get a copy. But you don't know when it will be read, and you have to plan for the letter arriving twice.

The rule of thumb: wait only for what your answer depends on. Checkout needs to know whether stock was reserved before it can say "confirmed". It doesn't need the confirmation email to have been sent.

sync-vs-async.mjs
import { setTimeout as sleep } from "node:timers/promises";

let emailUp = false; // the email service is down right now
const inventory = { reserve: async () => sleep(50) };
const email = {
  send: async (to) => {
    await sleep(200);
    if (!emailUp) throw new Error("email service unavailable");
  },
};

// Synchronous style: the order waits for every downstream call.
async function placeOrderSync(order) {
  await inventory.reserve(order);
  await email.send(order.email);
  return "confirmed";
}

// Asynchronous style: wait only for what the answer depends on; publish the rest.
const queue = [];
async function placeOrderAsync(order) {
  await inventory.reserve(order);
  queue.push({ type: "OrderPlaced", email: order.email, attempts: 0 });
  return "confirmed";
}
async function emailWorker() {
  while (queue.length) {
    const msg = queue.shift();
    try {
      await email.send(msg.email);
      console.log(`  worker: email sent on attempt ${msg.attempts + 1}`);
    } catch {
      msg.attempts++;
      queue.push(msg); // a real broker would redeliver with a delay
      emailUp = msg.attempts >= 2; // the service recovers after a while
    }
  }
}

async function time(label, fn) {
  const t = performance.now();
  try {
    console.log(`${label}: ${await fn()} in ${Math.round(performance.now() - t)} ms`);
  } catch (err) {
    console.log(`${label}: FAILED (${err.message}) in ${Math.round(performance.now() - t)} ms`);
  }
}

const order = { id: 1, email: "rohit@example.com" };
await time("sync ", () => placeOrderSync(order));
await time("async", () => placeOrderAsync(order));
await emailWorker();

// sync : FAILED (email service unavailable) in 252 ms
// async: confirmed in 51 ms
//   worker: email sent on attempt 3
What’s happening
  1. placeOrderSync awaits inventory (50 ms) and then the email service (200 ms). The email service is down, so after ~252 ms the whole order fails. The customer's checkout is broken by a service that has nothing to do with whether the order is valid — that's runtime coupling: the caller is only as available as everything it waits for.
  2. Worse, the sync version already reserved stock before it failed. Nothing releases it, so the stock is stuck. This is the partial failure problem every distributed design has to answer (see sagas).
  3. placeOrderAsync awaits only the inventory call, then pushes an OrderPlaced message and returns "confirmed" after ~51 ms. Its latency and availability no longer include the email service at all.
  4. emailWorker plays the consumer. Attempt 1 fails (attempts → 1), the message goes back on the queue; attempt 2 fails (attempts → 2), which also flips emailUp to true; attempt 3 succeeds. The email arrived late, but it arrived, and the customer never saw an error. A real broker provides the durable queue and the redelivery; here an array stands in for it.
  5. The price is that "confirmed" now means "confirmed, email pending" — the system is eventually consistent. The consumer also has to cope with redelivery, which means it must be idempotent.
AdvancedReference: sync vs async communication
Synchronous (HTTP, gRPC)Asynchronous (queue, event log)
Caller waits?Yes, until the reply or a timeoutNo, only until the broker accepts the message
CouplingCaller needs the callee up nowProducer and consumer can be up at different times
LatencyAdds the callee's latency to every requestProducer latency is just the publish
Load spikesPassed straight through to the calleeAbsorbed by the queue; consumers work at their own pace
Fan-outCaller must call each interested serviceOne event, any number of consumers
ConsistencyCaller knows the result immediatelyEventual; the result arrives later
Hard partsTimeouts, retries, cascading failureDuplicates, ordering, dead letters, tracing across hops
AdvancedPitfall: publishing and saving separately
Sync or async?

For each step of placing an order, would you call it synchronously or publish an event? (a) authorise the card, (b) send the confirmation email, (c) update the recommendations model, (d) check the item is in stock.

Show answer

(a) and (d) synchronous; (b) and (c) asynchronous.

What’s happening
  1. (a) The response to the customer depends on the card being authorised — "confirmed" is a lie otherwise — so the caller genuinely needs the answer now. Call it synchronously with a timeout (and an idempotency key, so a retry can't charge twice).
  2. (d) Same reasoning: you can't confirm an order for stock you don't have. Reserve it synchronously.
  3. (b) The email can be sent seconds later, and if the email provider is down the order should still succeed. Publish OrderPlaced and let a consumer send it.
  4. (c) Recommendations are a background concern with no effect on this response, and several other systems (analytics, warehouse) probably want the same event. That fan-out is exactly what async messaging is good at.
  5. A common variation for (a): accept the order as PENDING, authorise asynchronously and notify the customer. That trades an immediate answer for resilience to a slow payment provider — a product decision, not just a technical one.

#REST vs gRPC

Both are ways to make synchronous calls between services. REST models your API as resources (GET /orders/7, POST /orders) over plain HTTP, usually with JSON bodies. Anything can call it — a browser, curl, a partner in another company — and you can read the traffic with your eyes. gRPC is a contract-first RPC framework: you describe services and messages in a .proto file, generate client and server code from it, and calls travel as compact binary Protocol Buffers over HTTP/2, with built-in deadlines, status codes and streaming.

A mental model: REST is a shared public language — everyone understands it, it's self-describing, and it's a bit verbose. gRPC is a codebook agreed between two teams — messages are tiny and fast to decode, but only someone holding the same codebook (the .proto) can read them.

To see where the size difference comes from, here's a Protocol Buffers message encoded by hand next to the same data as JSON:

wire-size.mjs
// message ReserveRequest { string sku = 1; int32 qty = 2; int64 order_id = 3; }
function varint(n) {
  const bytes = [];
  while (n > 127) {
    bytes.push((n & 0x7f) | 0x80); // low 7 bits, "more bytes follow" flag
    n = Math.floor(n / 128);
  }
  bytes.push(n);
  return bytes;
}
const tag = (field, wireType) => varint((field << 3) | wireType);

function encodeReserveRequest({ sku, qty, orderId }) {
  const skuBytes = [...Buffer.from(sku, "utf8")];
  return Buffer.from([
    ...tag(1, 2), ...varint(skuBytes.length), ...skuBytes, // field 1, length-delimited
    ...tag(2, 0), ...varint(qty), // field 2, varint
    ...tag(3, 0), ...varint(orderId), // field 3, varint
  ]);
}

const msg = { sku: "SKU-1", qty: 150, orderId: 1234567 };
const proto = encodeReserveRequest(msg);
const json = Buffer.from(JSON.stringify(msg));
console.log("protobuf:", proto.length, "bytes", proto.toString("hex").match(/../g).join(" "));
console.log("JSON:    ", json.length, "bytes", json.toString());

// protobuf: 14 bytes 0a 05 53 4b 55 2d 31 10 96 01 18 87 ad 4b
// JSON:     43 bytes {"sku":"SKU-1","qty":150,"orderId":1234567}
What’s happening
  1. Protobuf never sends field names. Each field is sent as a tag — (fieldNumber << 3) | wireType — followed by the value. Field 1 with wire type 2 (length-delimited, used for strings) gives (1 << 3) | 2 = 10 = 0x0a. Field 2 with wire type 0 (varint) gives 0x10, field 3 gives 0x18.
  2. The sku string is sent as its length then its UTF-8 bytes: 05 then 53 4b 55 2d 31 ("SKU-1"). That's 7 bytes for the whole field, where JSON spends 13 on "sku":"SKU-1" plus punctuation.
  3. Numbers are varints: 7 bits per byte, with the top bit meaning "another byte follows". 150 is 10010110 in binary; the low 7 bits 0010110 plus the continue flag give 0x96, and the remaining 1 gives 0x01. So qty: 150 costs 3 bytes in total (10 96 01) instead of "qty":150.
  4. 1234567 takes three varint bytes (87 ad 4b): 1234567 % 128 is 7 → 0x87 with the flag, then 9645 % 128 is 45 → 0xad, then 75 → 0x4b. In JSON it's seven ASCII digits plus the "orderId": key.
  5. Result: 14 bytes versus 43. Decoding is also cheaper — no string scanning for quotes and colons, no number parsing from text. The cost is that the bytes mean nothing without the .proto: you can't read them in a log, and both sides must agree on what field 3 is. (This toy varint only handles non-negative numbers below 2³¹; the real library handles the rest.)
AdvancedgRPC in Node: the .proto contract

In real code you don't encode by hand. With @grpc/grpc-js and @grpc/proto-loader (illustrative — not installed here; it assumes an inventory.proto file next to the code):

inventory.proto
syntax = "proto3";
package inventory;

service Inventory {
  rpc Reserve (ReserveRequest) returns (ReserveReply);
  rpc WatchStock (StockQuery) returns (stream StockLevel); // server streaming
}
message ReserveRequest { string sku = 1; int32 qty = 2; int64 order_id = 3; }
message ReserveReply   { bool reserved = 1; int32 remaining = 2; }
message StockQuery     { string sku = 1; }
message StockLevel     { string sku = 1; int32 qty = 2; }
What’s happening
  1. The .proto file is the contract both teams share. service Inventory lists the RPCs: Reserve is unary (one request, one reply); WatchStock returns a stream, so the server can send many StockLevel messages over one call.
  2. The numbers (= 1, = 2, = 3) are the field numbers that go on the wire as tags — the field names never do. That's why they must never change or be reused once deployed: an old client would decode field 3 with the old meaning.
  3. Adding a new field with a new number is backwards compatible: old readers skip tags they don't know, and in proto3 a missing field reads as its default (0, "", false). That's how gRPC APIs evolve without versioned URLs.
Advancedgrpc-inventory.cjs: server and client
grpc-inventory.cjs
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");

const definition = protoLoader.loadSync(__dirname + "/inventory.proto", {
  keepCase: false, // order_id becomes orderId in JS
  longs: String, // int64 arrives as a string: JS numbers lose precision above 2^53
  defaults: true,
});
const { inventory } = grpc.loadPackageDefinition(definition);

// --- server ---
const stock = new Map([["SKU-1", 5]]);
const server = new grpc.Server();
server.addService(inventory.Inventory.service, {
  reserve(call, callback) {
    const { sku, qty } = call.request;
    const left = stock.get(sku) ?? 0;
    if (left < qty) return callback({ code: grpc.status.FAILED_PRECONDITION, details: "not enough stock" });
    stock.set(sku, left - qty);
    callback(null, { reserved: true, remaining: left - qty });
  },
  watchStock(call) {
    call.write({ sku: call.request.sku, qty: stock.get(call.request.sku) ?? 0 });
    call.end();
  },
});
server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (err) => {
  if (err) throw err;

  // --- client (normally another service) ---
  const client = new inventory.Inventory("localhost:50051", grpc.credentials.createInsecure());
  client.reserve({ sku: "SKU-1", qty: 2, orderId: "1234567" }, { deadline: Date.now() + 500 }, (err, reply) => {
    if (err?.code === grpc.status.DEADLINE_EXCEEDED) console.log("inventory too slow");
    else if (err) console.log(grpc.status[err.code], err.details);
    else console.log(reply); // { reserved: true, remaining: 3 }
    client.close();
    server.forceShutdown();
  });
});
What’s happening
  1. protoLoader.loadSync parses the .proto at runtime (the alternative is generating static code with protoc). longs: String matters in JavaScript: an int64 can exceed Number.MAX_SAFE_INTEGER, so it's delivered as a string rather than a silently rounded number. keepCase: false converts order_id to orderId.
  2. grpc.loadPackageDefinition turns the definition into objects: inventory.Inventory is a client constructor, and inventory.Inventory.service is the service description the server needs.
  3. server.addService maps each RPC to a handler. A unary handler gets call.request (already decoded) and a callback(error, reply). Errors are gRPC status codes such as FAILED_PRECONDITION, not HTTP codes. A streaming handler (watchStock) writes any number of messages and then calls end().
  4. bindAsync binds the port and starts serving (older @grpc/grpc-js versions also required a separate server.start() call). This example uses insecure credentials for brevity; between services you'd use TLS or mTLS credentials.
  5. The client call passes { deadline: Date.now() + 500 }. The deadline travels with the request, so the server can see how much time is left and stop work the caller has already given up on — something you have to build yourself with REST (with an AbortSignal locally, and a header to pass the budget on).
  6. If the server takes longer than 500 ms, the callback gets DEADLINE_EXCEEDED. That's the gRPC equivalent of the gateway's 504 in the API gateway example: every remote call should have one.
AdvancedReference: REST vs gRPC
REST / JSONgRPC / Protobuf
ContractOptional (OpenAPI), often informalRequired .proto, generated typed clients
PayloadText, self-describing, readable in logsBinary, compact, needs the schema to read
TransportHTTP/1.1 or HTTP/2HTTP/2 only
StreamingNot built in (use SSE, WebSockets, chunked responses)Client, server and bidirectional streaming
BrowsersNativeNeeds gRPC-Web plus a proxy
CachingHTTP caching, CDNs, ETags work out of the boxNo HTTP caching
Best fitPublic APIs, browser clients, simple CRUDInternal high-volume calls, polyglot contracts, streaming
AdvancedEdge case: connection-level balancing

#Kafka vs RabbitMQ

Both are message brokers, but they are built around different data structures, and that difference drives everything else.

RabbitMQ is a queue broker. Producers publish to an exchange, which routes each message into one or more queues by rules (direct, topic patterns, fan-out). Consumers of a queue compete: each message is delivered to one consumer, and once that consumer acknowledges it, the message is deleted. The broker tracks per-message state — delivered, unacked, redelivered — and supports dead-letter queues, priorities and per-message routing.

Kafka is a distributed, partitioned, append-only log. Producers append events to a topic, which is split into partitions. Events are kept for a retention period (days, or forever) whether or not anyone has read them. Consumers don't delete anything; each consumer group just remembers an offset — "I've processed up to event 1,042 in partition 3". Every group gets every event, groups move at their own pace, and a group can rewind its offset to replay history.

The mental model: RabbitMQ is a post office. Letters are sorted into mailboxes; when a postman picks one up and delivers it, it's gone from the office. Kafka is a newspaper archive. Every edition is kept on the shelf; each reader has a bookmark; a new reader can start from the first edition, and nobody's reading removes anything for anyone else.

queue-vs-log.mjs
// Queue (RabbitMQ/SQS model): each message goes to ONE consumer, then it's gone.
class WorkQueue {
  messages = [];
  consumers = [];
  next = 0;
  publish(m) {
    this.messages.push(m);
  }
  dispatch() {
    while (this.messages.length) {
      const m = this.messages.shift(); // removed once acked
      const consumer = this.consumers[this.next++ % this.consumers.length]; // round robin
      consumer(m);
    }
  }
}

// Log (Kafka model): messages are appended and kept; each group tracks its own offset.
class Log {
  entries = [];
  offsets = new Map(); // groupId -> next offset to read
  append(m) {
    this.entries.push(m);
  }
  poll(groupId) {
    const from = this.offsets.get(groupId) ?? 0;
    const batch = this.entries.slice(from);
    this.offsets.set(groupId, this.entries.length); // "commit" the offset
    return batch;
  }
  seek(groupId, offset) {
    this.offsets.set(groupId, offset);
  }
}

const q = new WorkQueue();
q.consumers.push((m) => console.log(`  worker-1 resized ${m}`), (m) => console.log(`  worker-2 resized ${m}`));
["img-1", "img-2", "img-3"].forEach((m) => q.publish(m));
console.log("queue:");
q.dispatch();
console.log("  left in queue:", q.messages.length);

const log = new Log();
["order-1", "order-2", "order-3"].forEach((m) => log.append(m));
console.log("log:");
console.log("  analytics reads:", log.poll("analytics"));
console.log("  warehouse reads:", log.poll("warehouse"));
log.append("order-4");
console.log("  analytics reads:", log.poll("analytics"));
log.seek("analytics", 0); // replay after fixing a bug
console.log("  analytics replays:", log.poll("analytics"));
console.log("  entries kept:", log.entries.length);

// queue:
//   worker-1 resized img-1
//   worker-2 resized img-2
//   worker-1 resized img-3
//   left in queue: 0
// log:
//   analytics reads: [ 'order-1', 'order-2', 'order-3' ]
//   warehouse reads: [ 'order-1', 'order-2', 'order-3' ]
//   analytics reads: [ 'order-4' ]
//   analytics replays: [ 'order-1', 'order-2', 'order-3', 'order-4' ]
//   entries kept: 4
What’s happening
  1. The queue has two competing consumers. dispatch hands img-1 to worker-1, img-2 to worker-2 and img-3 back to worker-1 (round robin — RabbitMQ does roughly this, limited by each consumer's prefetch). Each job is done once, by one worker, which is exactly what you want for "resize this image" or "send this email".
  2. After dispatch, left in queue: 0: delivered and acknowledged messages are gone. A service that subscribes tomorrow can't see yesterday's jobs.
  3. The log appends three orders. The analytics group polls from offset 0 and gets all three; its offset becomes 3. The warehouse group has its own offset, also starting at 0, so it also gets all three. Two independent systems consumed the same events without the producer knowing either exists.
  4. order-4 is appended. analytics resumes from its offset 3 and gets only order-4. Offsets are the consumer's bookmark; the broker doesn't track per-message delivery.
  5. seek("analytics", 0) rewinds the bookmark and the next poll replays all four events — e.g. after fixing a bug in the analytics consumer, or to build a brand-new read model from history. Nothing was deleted: entries kept: 4. Replay is the feature a queue fundamentally can't offer.
  6. What the toy leaves out: real Kafka splits a topic into partitions, and within one consumer group each partition is read by exactly one consumer — so a group's members share the work (like a queue), while different groups each see everything (like the log above).
AdvancedReal clients: a RabbitMQ worker

Here's what the same two roles look like with the real Node clients (illustrative: they assume a RabbitMQ on localhost:5672 and a Kafka broker on localhost:9092, with amqplib and kafkajs 2 installed).

rabbit-worker.mjs
import amqp from "amqplib";

const conn = await amqp.connect("amqp://localhost");
const ch = await conn.createChannel();
await ch.assertQueue("emails", { durable: true }); // the queue survives a broker restart

// Producer: persistent messages are written to disk by the broker.
const ok = ch.sendToQueue("emails", Buffer.from(JSON.stringify({ to: "a@b.com" })), {
  persistent: true,
  messageId: "evt-1",
});
if (!ok) await new Promise((resolve) => ch.once("drain", resolve)); // backpressure, like stream.write()

// Consumer: at most 10 unacked messages in flight for this consumer.
await ch.prefetch(10);
await ch.consume("emails", async (msg) => {
  if (msg === null) return; // the broker cancelled this consumer
  try {
    await sendEmail(JSON.parse(msg.content.toString()));
    ch.ack(msg); // only now is the message deleted
  } catch {
    ch.nack(msg, false, false); // don't requeue: goes to the dead-letter exchange if one is configured
  }
});
What’s happening
  1. assertQueue("emails", { durable: true }) creates the queue if it doesn't exist; durable means the queue definition survives a broker restart, and persistent: true on each message means the broker writes it to disk. You need both for messages to survive a restart.
  2. sendToQueue returns false when the channel's write buffer is full. Like stream.write() in Backpressure, false means "accepted, but slow down" — wait for the channel's drain event before publishing more. (For confirmation that the broker has actually stored a message, use conn.createConfirmChannel() and wait for publisher confirms.)
  3. prefetch(10) caps how many unacknowledged messages the broker pushes to this consumer. Without it the broker can push the whole backlog into one Node process's memory, and a slow consumer hoards work other consumers could have done.
  4. The consumer acknowledges only after the work succeeds. If the process dies before ack, the broker sees the channel close and redelivers the message to another consumer — that's at-least-once delivery, so sendEmail must tolerate duplicates.
  5. nack(msg, false, false) rejects without requeueing. If you requeue a message that always fails (a "poison message"), it loops forever; routing it to a dead-letter queue lets you inspect it while the rest keep flowing.
Advancedkafka-orders.mjs: producer and consumer
kafka-orders.mjs
import { Kafka } from "kafkajs";

const kafka = new Kafka({ clientId: "order-service", brokers: ["localhost:9092"] });

// Producer: the key decides the partition, so all events for one order stay in order.
const producer = kafka.producer();
await producer.connect();
await producer.send({
  topic: "orders",
  messages: [{ key: "order-123", value: JSON.stringify({ type: "OrderCreated", orderId: "order-123" }) }],
});

// Consumer: every instance with groupId "analytics" shares the partitions between them.
const consumer = kafka.consumer({ groupId: "analytics" });
await consumer.connect();
await consumer.subscribe({ topics: ["orders"], fromBeginning: true });
await consumer.run({
  eachMessage: async ({ partition, message }) => {
    const event = JSON.parse(message.value.toString());
    await recordInWarehouse(event); // if this throws, the offset isn't committed and the message is retried
    console.log(`p${partition} @${message.offset}`, message.key.toString(), event.type);
  },
});
What’s happening
  1. producer.send appends the event to the orders topic. The key is hashed to pick a partition, so every event for order-123 lands in the same partition — and Kafka only guarantees order within a partition. Choose keys by "what must stay in order" (an order ID, an account ID).
  2. kafka.consumer({ groupId: "analytics" }) joins the analytics consumer group. If you run three instances of this service, Kafka splits the topic's partitions between them; with six partitions each gets two. A group can never have more active consumers than partitions, so the partition count caps your parallelism.
  3. fromBeginning: true only applies when the group has no committed offset yet: a brand-new group starts at the oldest retained event instead of only new ones. That's how a new service builds its state from history.
  4. eachMessage handles one message at a time per partition. KafkaJS commits the offset after your handler resolves (auto-commit is on by default). If the handler throws, the offset isn't committed and the message is retried, so processing is at-least-once — the handler must be idempotent.
  5. A message that always throws blocks its whole partition, because later offsets can't be committed past it. Production consumers catch, retry a bounded number of times, then publish the event to a dead-letter topic and move on.
AdvancedReference: RabbitMQ vs Kafka
RabbitMQ (and SQS-style queues)Kafka
Core structureQueues; message deleted after ackPartitioned log; events retained for a period
ConsumersCompete for messages in a queueEach group gets every event; partitions are shared within a group
ReplayNo (once acked, it's gone)Yes, rewind the offset
OrderingPer queue, weakened by multiple consumers and redeliveryPer partition (choose the key carefully)
RoutingRich: exchanges, topic patterns, headers, prioritiesBy topic and key only
Typical useTask queues, background jobs, per-message retries and DLQs, RPC-style workEvent streaming, many independent consumers, high throughput, event sourcing, change data capture

These are tendencies, not walls: RabbitMQ has "streams" with replay, and Kafka can serve as a work queue. In an interview, explain the data structure and let the use case follow from it.

#Delivery guarantees and idempotency

Every messaging system gives you one of three delivery guarantees:

  • At-most-once: the message is delivered zero or one times. Acknowledge first, then process — if you crash mid-way, the message is lost. Fine for metrics, bad for payments.
  • At-least-once: the message is delivered one or more times. Process first, then acknowledge — if you crash after processing but before the ack reaches the broker, the broker redelivers. This is the default for RabbitMQ, SQS and Kafka consumers.
  • Exactly-once: the message has its effect exactly once. Over an unreliable network you can't get this from delivery alone, because the sender can never tell "the receiver never got it" apart from "the receiver got it but the ack was lost" — so it has to choose between resending (maybe twice) and not resending (maybe never).

What you can build is exactly-once processing: at-least-once delivery plus an idempotent consumer, one where handling the same message twice has the same effect as handling it once. The mental model is a pharmacist with numbered prescriptions: when a slip comes in, they check whether that number is already stamped "filled". A duplicate slip gets a polite "already done", not a second bottle of pills.

idempotent.mjs
// A tiny at-least-once broker: a message stays until it's acked; a crash means redelivery.
function deliverAtLeastOnce(messages, handler) {
  const queue = [...messages];
  while (queue.length) {
    const msg = queue[0];
    try {
      handler(msg);
      queue.shift(); // ack
    } catch (err) {
      console.log(`  consumer crashed (${err.message}) before ack -> ${msg.id} redelivered`);
    }
  }
}

let crashOnce = true;
function crashBeforeAck() {
  if (crashOnce) {
    crashOnce = false;
    throw new Error("pod killed");
  }
}

const events = [
  { id: "evt-1", type: "ChargeCustomer", orderId: 123, amount: 50 },
  { id: "evt-2", type: "ChargeCustomer", orderId: 124, amount: 20 },
];

console.log("naive consumer:");
const naiveCharges = [];
deliverAtLeastOnce(events, (msg) => {
  naiveCharges.push(msg.orderId); // the side effect
  crashBeforeAck();
});
console.log("  charges:", naiveCharges);

console.log("idempotent consumer:");
crashOnce = true;
const processed = new Set(); // in real life: a table with a UNIQUE key
const charges = [];
deliverAtLeastOnce(events, (msg) => {
  if (processed.has(msg.id)) {
    console.log(`  ${msg.id} already processed -> ack and skip`);
    return;
  }
  charges.push(msg.orderId);
  processed.add(msg.id);
  crashBeforeAck();
});
console.log("  charges:", charges);

// naive consumer:
//   consumer crashed (pod killed) before ack -> evt-1 redelivered
//   charges: [ 123, 123, 124 ]
// idempotent consumer:
//   consumer crashed (pod killed) before ack -> evt-1 redelivered
//   evt-1 already processed -> ack and skip
//   charges: [ 123, 124 ]
What’s happening
  1. deliverAtLeastOnce keeps a message at the head of the queue until the handler returns normally (the "ack", queue.shift()). If the handler throws, the message stays and is delivered again — just like a broker that never received the ack.
  2. The naive consumer charges order 123 (naiveCharges → [123]) and then "crashes" before acking. evt-1 is redelivered, the handler runs again and charges 123 a second time ([123, 123]). evt-2 then goes through normally. The customer was charged twice — the ChargeCustomer story from my notes.
  3. The idempotent consumer first checks processed.has(msg.id). On the first delivery evt-1 isn't there, so it charges 123, records evt-1 in processed, then crashes before the ack.
  4. On redelivery processed.has("evt-1") is true, so it logs "already processed", returns normally — which acks the message so the broker stops redelivering — and charges nothing. Final charges: [123, 124].
  5. The key is the message's stable ID: the producer assigns evt-1 once, and every redelivery carries the same ID. Deduplicating on content ("same order, same amount") would wrongly drop a genuine second purchase.
  6. This demo cheats in one important way: a real "pod killed" would wipe the in-memory Set too. The processed-IDs record has to be durable and written atomically with the effect — that's the next example.
AdvancedIdempotency in the same transaction

The production version stores processed IDs in the database, in the same transaction as the change the message causes. Then either both happen or neither does. This runs on Node's built-in node:sqlite (no flag needed on Node 26):

idempotent-sqlite.mjs
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE processed_events (event_id TEXT PRIMARY KEY);
  CREATE TABLE ledger (order_id INTEGER, amount INTEGER);
`);
const markProcessed = db.prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING");
const addCharge = db.prepare("INSERT INTO ledger (order_id, amount) VALUES (?, ?)");

function handle(msg) {
  db.exec("BEGIN");
  try {
    const { changes } = markProcessed.run(msg.id);
    if (changes === 0) {
      db.exec("ROLLBACK");
      return "duplicate, skipped";
    }
    addCharge.run(msg.orderId, msg.amount);
    db.exec("COMMIT");
    return "charged";
  } catch (err) {
    db.exec("ROLLBACK");
    throw err;
  }
}

const msg = { id: "evt-1", orderId: 123, amount: 50 };
console.log(handle(msg)); // first delivery
console.log(handle(msg)); // redelivery after a lost ack
console.log(db.prepare("SELECT * FROM ledger").all());

// charged
// duplicate, skipped
// [ [Object: null prototype] { order_id: 123, amount: 50 } ]
What’s happening
  1. processed_events.event_id is a PRIMARY KEY, so the database itself refuses a second row with the same ID. That's stronger than "check then insert" in application code, which two concurrent consumers could both pass.
  2. First delivery: BEGIN opens a transaction. INSERT … ON CONFLICT DO NOTHING inserts evt-1 and reports changes: 1, so the handler adds the ledger row and COMMITs. The dedup record and the charge become durable together.
  3. Second delivery (the broker never got the ack): the same INSERT hits the existing key, ON CONFLICT DO NOTHING makes it a no-op, and changes is 0. The handler rolls back and returns "duplicate, skipped" — the consumer then acks so the broker stops.
  4. If the process died after addCharge but before COMMIT, the database would roll back both rows (see ACID transactions). The redelivery would find no evt-1 and process it properly. There's no window where the charge exists but the mark doesn't, or the other way round.
  5. The ledger holds exactly one row. (Rows from node:sqlite have a null prototype, which is why Node prints [Object: null prototype].)
AdvancedIdempotency keys for external side effects

When the side effect lives outside your database — charging a card at a payment provider, sending an email — you can't put it in your transaction. Then pass an idempotency key to the provider (payment APIs such as Stripe accept an Idempotency-Key header and return the original result for a repeated key), derived from the event ID. And prefer operations that are naturally idempotent: "set order status to SHIPPED" can run twice harmlessly; "add 1 to shipped count" can't.

AdvancedCaveat: the scope of exactly-once
Which order is safe?

In the in-memory idempotent consumer, what happens if the crash happens after charges.push but before processed.add? And what if you swap the two lines, marking first and charging second?

Show answer

As written, a crash between the two lines double-charges. With the lines swapped, the same crash loses the charge. Neither order is safe on its own.

What’s happening
  1. As written: the charge happens, then the process dies before the ID is recorded. On redelivery processed.has(id) is false, so the handler charges again — the same bug as the naive consumer, just with a smaller window.
  2. Swapped: the ID is recorded, then the process dies before charging. On redelivery processed.has(id) is true, so the handler skips — and the customer is never charged. That's at-most-once behaviour.
  3. The only real fix is to make "record the ID" and "do the effect" one atomic step: the same database transaction (the node:sqlite version), or, for an external effect, an idempotency key that the external system itself deduplicates on.

#Retries with exponential backoff and jitter

Networks drop packets, pods restart and services shed load, so many failures are transient: the same call a moment later succeeds. Retrying turns those blips into slightly slower successes. But retrying badly makes outages worse. If every client retries immediately when a struggling service fails, the service gets several times its normal traffic exactly when it can handle the least — a retry storm.

My notes list "retries, with exponential backoff (e.g. axios-retry)". The full recipe has four parts:

  • Exponential backoff: wait longer after each failure — base × 2^attempt (100 ms, 200 ms, 400 ms…), capped at a maximum — so a struggling service gets breathing room.
  • Jitter: randomise each wait. Without it, a thousand clients that failed at the same moment retry at the same moment, again and again. "Full jitter" waits a random time between 0 and the current cap.
  • Retry only what's transient and safe: timeouts, connection resets, 429, 502, 503 — not 400 or 403, which will fail identically forever. And only retry operations that are idempotent (or carry an idempotency key), or a timed-out POST /charges might charge twice.
  • Bound it: a maximum number of attempts, and stop as soon as the caller's overall deadline has passed.

The mental model is a crowd at a locked door. If everyone knocks again exactly every second, the door gets hammered at every tick. If each person waits longer after each failed knock, and adds a random pause, the knocks spread out and whoever opens the door isn't trampled.

retry.mjs
import { setTimeout as sleep } from "node:timers/promises";

// Full jitter: wait a random amount between 0 and the exponential cap.
export async function retry(fn, { retries = 3, baseMs = 100, maxMs = 2_000, signal, random = Math.random, shouldRetry = isTransient, onRetry } = {}) {
  for (let attempt = 0; ; attempt++) {
    signal?.throwIfAborted();
    try {
      return await fn({ attempt, signal });
    } catch (err) {
      if (attempt >= retries || !shouldRetry(err)) throw err;
      const cap = Math.min(maxMs, baseMs * 2 ** attempt);
      const delay = Math.round(random() * cap);
      onRetry?.({ attempt: attempt + 1, err, cap, delay });
      await sleep(delay, undefined, { signal });
    }
  }
}

function isTransient(err) {
  if (["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED"].includes(err.code)) return true;
  return err.status === 429 || err.status === 503 || err.status === 502;
}
What’s happening
  1. The loop counts attempts from 0. Each pass first calls signal?.throwIfAborted(), so if the caller's deadline has already passed, no new attempt starts. fn receives the signal too, so it can pass it to fetch and cancel the attempt itself.
  2. A success returns immediately. A failure is rethrown unchanged if we've used all retries (attempt >= retries) or shouldRetry(err) says it's permanent — so a 400 surfaces on the first failure, with its original error.
  3. Otherwise the cap doubles each time: attempt 0 → min(2000, 100 × 1) = 100, attempt 1 → 200, attempt 2 → 400, then 800, 1,600, and 2,000 from then on. maxMs stops the wait growing without limit.
  4. random() * cap is the full jitter: the actual wait is anywhere from 0 to the cap. random is a parameter only so a demo or a test can make it deterministic; in production it's Math.random.
  5. sleep(delay, undefined, { signal }) from node:timers/promises waits without blocking the event loop, and rejects with an AbortError the moment the signal fires — so a cancelled request stops waiting mid-backoff instead of sleeping out the full delay (see Cancelling work with AbortController).
AdvancedRetry demo: flaky, permanent and deadline cases

Now run it against a flaky inventory service, a permanent error and a caller deadline. A seeded random number generator makes the "random" delays the same on every run:

retry-demo.mjs
import { retry } from "./retry.mjs";

// A seeded random so the "random" delays are the same on every run.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Fails with ECONNRESET three times, then succeeds.
let calls = 0;
async function flakyInventory() {
  calls++;
  if (calls <= 3) throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
  return { reserved: true };
}

const start = performance.now();
const result = await retry(flakyInventory, {
  retries: 4,
  baseMs: 100,
  random: mulberry32(42),
  onRetry: ({ attempt, cap, delay }) => console.log(`retry ${attempt}: cap ${cap} ms, waiting ${delay} ms`),
});
console.log(result, `after ${calls} calls, ~${Math.round(performance.now() - start)} ms`);

// A 400 is permanent: no retries at all.
calls = 0;
try {
  await retry(async () => { calls++; throw Object.assign(new Error("bad sku"), { status: 400 }); });
} catch (err) {
  console.log(`${err.message}: gave up after ${calls} call`);
}

// The caller's deadline cancels the wait between attempts.
try {
  calls = 0;
  await retry(async () => { calls++; throw Object.assign(new Error("down"), { status: 503 }); }, {
    retries: 10, baseMs: 200, random: () => 1, signal: AbortSignal.timeout(500),
    onRetry: ({ attempt, delay }) => console.log(`retry ${attempt}: waiting ${delay} ms`),
  });
} catch (err) {
  console.log(`${err.name} (cause: ${err.cause.name}) after ${calls} calls`);
}

// retry 1: cap 100 ms, waiting 60 ms
// retry 2: cap 200 ms, waiting 90 ms
// retry 3: cap 400 ms, waiting 341 ms
// { reserved: true } after 4 calls, ~498 ms
// bad sku: gave up after 1 call
// retry 1: waiting 200 ms
// retry 2: waiting 400 ms
// AbortError (cause: TimeoutError) after 2 calls
What’s happening
  1. Call 1 fails with ECONNRESET, which isTransient accepts. The cap is 100 ms; the seeded generator's first value is 0.601…, so the wait is round(0.601 × 100) = 60 ms.
  2. Call 2 fails; the cap doubles to 200 and the next random value 0.448… gives 90 ms. Call 3 fails; the cap is 400 and 0.852… gives 341 ms. Notice the waits aren't neatly doubling — that's the jitter; only the ceiling doubles.
  3. Call 4 succeeds and retry returns { reserved: true }. Total time is the three waits (60 + 90 + 341 = 491 ms) plus a few milliseconds of overhead — about 495–500 ms; the exact figure varies a little between runs.
  4. The 400 error fails shouldRetry, so retry rethrows it straight away: one call, no waiting. Retrying a validation error only adds load and latency for a guaranteed failure.
  5. The last case uses random: () => 1 (no jitter) so the waits are exactly 200 ms then 400 ms, under an overall deadline of AbortSignal.timeout(500). Call 1 fails at ~0 ms, wait 200; call 2 fails at ~200 ms, wait 400 — but the signal fires at 500 ms, mid-sleep. sleep rejects with an AbortError whose cause is the signal's TimeoutError, and retry doesn't catch errors from the sleep, so it propagates. Only 2 calls were made, and the caller got its answer at its deadline rather than after ten retries.
AdvancedWhy jitter matters: 1,000 clients

Why jitter matters is easiest to see with a crowd. Here 1,000 clients all fail at the same instant and schedule their first retry:

herd.mjs
// 1,000 clients all fail at t = 0 and schedule their first retry (cap = 100 ms).
const clients = 1_000;
function busiest(delays) {
  const buckets = new Map(); // 10 ms buckets
  for (const d of delays) {
    const b = Math.floor(d / 10);
    buckets.set(b, (buckets.get(b) ?? 0) + 1);
  }
  return Math.max(...buckets.values());
}
const noJitter = Array.from({ length: clients }, () => 100);
const fullJitter = Array.from({ length: clients }, () => Math.random() * 100);
console.log("no jitter:   busiest 10 ms window has", busiest(noJitter), "retries");
console.log("full jitter: busiest 10 ms window has", busiest(fullJitter), "retries");

// no jitter:   busiest 10 ms window has 1000 retries
// full jitter: busiest 10 ms window has 115 retries   (about 105–130, varies per run)
What’s happening
  1. busiest drops every retry time into a 10 ms bucket (Math.floor(d / 10)) and reports the fullest bucket — the worst instantaneous load the recovering service sees.
  2. Without jitter all 1,000 retries land at exactly 100 ms, in one bucket: the service gets the whole crowd at once, fails again, and the same crowd comes back at 300 ms, then 700 ms. Backoff alone spaces the waves out but keeps them as waves.
  3. With full jitter the retries spread uniformly over 0–100 ms. Ten buckets share 1,000 retries, so the busiest gets a little over the average of 100 — between about 105 and 130 across 40 runs, because uniform random isn't perfectly even.
  4. The total number of retries is the same; jitter changes the peak, which is what knocks a service over. That's why AWS's architecture guidance and most retry libraries recommend jitter, not just backoff.
Advancedaxios-retry for axios clients

The axios-retry package from my notes wraps the same ideas for axios (illustrative; assumes axios and axios-retry are installed):

axios-retry.mjs
import axios from "axios";
import axiosRetry from "axios-retry";

const inventory = axios.create({ baseURL: "http://inventory", timeout: 500 });
axiosRetry(inventory, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay, // grows exponentially, with a small random component
  retryCondition: axiosRetry.isNetworkOrIdempotentRequestError, // network errors and 5xx, idempotent methods only
});
What’s happening
  1. axios.create makes a client with a timeout of 500 ms. Check how that timeout interacts with retries (axios-retry's shouldResetTimeout option decides whether each retry gets a fresh timeout), so that the worst case — every attempt plus every wait — stays inside the caller's own deadline.
  2. axiosRetry(inventory, …) installs an interceptor on that instance only, so other clients in the process keep their own policies.
  3. retryCondition: isNetworkOrIdempotentRequestError retries network errors and 5xx responses only for idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE). A POST isn't retried by default — exactly the safety rule above. If you make POSTs retryable, send an idempotency key with them.
AdvancedPitfall: retry amplification
Worst-case wait

With retries: 3, baseMs: 100, maxMs: 2000 and full jitter, what is the longest retry can spend waiting (excluding the calls themselves)? What's the average?

Show answer

Longest: 700 ms. Average: about 350 ms.

What’s happening
  1. Three retries means four attempts and three waits, after attempts 0, 1 and 2.
  2. The caps are min(2000, 100 × 2⁰) = 100, then 200, then 400. None reaches maxMs.
  3. Full jitter waits random() × cap, and random() is below 1, so the worst case approaches 100 + 200 + 400 = 700 ms.
  4. Math.random() averages 0.5, so the expected wait is half of each cap: 50 + 100 + 200 = 350 ms. Full jitter halves the average wait compared with plain backoff, as well as spreading the load.

#Circuit breakers and fallbacks

Retries help with blips. They don't help when a dependency is down for minutes: every request still waits for a timeout, retries, waits again, and fails. Meanwhile your sockets, memory and latency budget are used up waiting on something that isn't going to answer, and your own callers start timing out — that's how one sick service takes down the services around it (a cascading failure).

A circuit breaker wraps calls to a dependency and watches the results. It's named after the electrical breaker in your house: when too much current flows, it trips and cuts the circuit, so the wiring doesn't burn; after you've fixed the fault, you reset it. In software it has three states:

  • CLOSED — normal. Calls go through; failures are counted.
  • OPEN — after too many failures, the breaker trips. Calls fail fast without touching the dependency, and the caller uses a fallback. This gives the dependency room to recover and frees your resources immediately.
  • HALF-OPEN — after a cool-down, the breaker lets a probe call through. If it succeeds, the breaker closes; if it fails, the breaker opens again for another cool-down.
CLOSED calls go through failures are counted OPEN fail fast, no call made serve the fallback HALF-OPEN one probe call allowed failures ≥ threshold cool-down elapsed probe succeeds probe fails
A breaker trades a slow failure for a fast one, and keeps checking whether the dependency has recovered.

A small breaker with a consecutive-failure threshold, a cool-down and a single probe:

breaker.mjs
export class CircuitOpenError extends Error {
  name = "CircuitOpenError";
}

export class CircuitBreaker {
  #state = "CLOSED";
  #failures = 0;
  #openedAt = 0;
  #probing = false;

  constructor(fn, { failureThreshold = 3, resetMs = 10_000, timeoutMs = 1_000, now = Date.now } = {}) {
    Object.assign(this, { fn, failureThreshold, resetMs, timeoutMs, now });
  }

  get state() {
    return this.#state;
  }

  async call(...args) {
    if (this.#state === "OPEN") {
      if (this.now() - this.#openedAt < this.resetMs) throw new CircuitOpenError("circuit open: failing fast");
      this.#state = "HALF_OPEN"; // the cool-down is over: let one probe through
    }
    if (this.#state === "HALF_OPEN") {
      if (this.#probing) throw new CircuitOpenError("circuit half-open: probe in flight");
      this.#probing = true;
    }
    try {
      const result = await this.fn(...args, AbortSignal.timeout(this.timeoutMs));
      this.#onSuccess();
      return result;
    } catch (err) {
      this.#onFailure();
      throw err;
    } finally {
      this.#probing = false;
    }
  }

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

  #onFailure() {
    this.#failures++;
    if (this.#state === "HALF_OPEN" || this.#failures >= this.failureThreshold) {
      this.#state = "OPEN";
      this.#openedAt = this.now();
    }
  }
}
What’s happening
  1. The state lives in private fields: #state, a count of consecutive #failures, the time it last opened, and whether a probe is in flight. now is injectable so the demo can use a fake clock instead of waiting real seconds.
  2. In call, an OPEN breaker first checks the clock. Inside the cool-down it throws CircuitOpenError immediately — no network call, no waiting, no socket used. Once resetMs has passed it switches to HALF_OPEN and falls through.
  3. In HALF_OPEN only one probe may run. The first caller sets #probing = true; anyone arriving while it's in flight fails fast. Without this, the moment the cool-down ended, every queued request would rush the recovering service at once.
  4. The call itself gets AbortSignal.timeout(timeoutMs) as its last argument. A breaker without timeouts is useless against a hanging service, because a call that never finishes never counts as a failure.
  5. #onSuccess resets the count and closes the breaker. #onFailure increments the count and opens the breaker if the threshold is reached — or immediately if the failure was the half-open probe. finally clears the probe flag either way.
AdvancedTracing the breaker with a fake clock

Now trace it with a fake clock, a recommendations service that's down and a cached fallback:

breaker-demo.mjs
import { CircuitBreaker } from "./breaker.mjs";

let clock = 0; // fake time in ms, so the trace is exact
let healthy = false;
let downstreamCalls = 0;

async function getRecommendations(userId, signal) {
  downstreamCalls++;
  await new Promise((resolve) => setImmediate(resolve)); // pretend network round trip
  if (!healthy) throw new Error("503 from recommendations");
  return ["film-1", "film-2"];
}

const breaker = new CircuitBreaker(getRecommendations, { failureThreshold: 3, resetMs: 10_000, now: () => clock });
const lastGood = ["cached-film"]; // fallback

async function request(label) {
  let outcome;
  try {
    outcome = await breaker.call("u1");
  } catch (err) {
    outcome = `${err.name === "CircuitOpenError" ? "fast fail" : "error"} -> fallback ${lastGood}`;
  }
  console.log(`t=${String(clock).padStart(5)}  ${label.padEnd(9)} ${breaker.state.padEnd(9)} calls=${downstreamCalls}  ${outcome}`);
}

await request("call 1");
await request("call 2");
await request("call 3"); // third failure trips it
await request("call 4"); // rejected without touching the service
clock = 4_000;
await request("call 5");
clock = 10_000;
await Promise.all([request("probe 1"), request("call 6")]); // cool-down over: one probe, still unhealthy
clock = 15_000;
healthy = true;
await request("call 7"); // still inside the new cool-down
clock = 20_000;
await request("probe 2"); // succeeds
await request("call 8");

// t=    0  call 1    CLOSED    calls=1  error -> fallback cached-film
// t=    0  call 2    CLOSED    calls=2  error -> fallback cached-film
// t=    0  call 3    OPEN      calls=3  error -> fallback cached-film
// t=    0  call 4    OPEN      calls=3  fast fail -> fallback cached-film
// t= 4000  call 5    OPEN      calls=3  fast fail -> fallback cached-film
// t=10000  call 6    HALF_OPEN calls=4  fast fail -> fallback cached-film
// t=10000  probe 1   OPEN      calls=4  error -> fallback cached-film
// t=15000  call 7    OPEN      calls=4  fast fail -> fallback cached-film
// t=20000  probe 2   CLOSED    calls=5  film-1,film-2
// t=20000  call 8    CLOSED    calls=6  film-1,film-2
What’s happening
  1. CLOSED. Calls 1 and 2 reach the service (calls 1 → 2) and fail; the breaker counts #failures 1 → 2 and stays closed. Each user still gets the cached fallback instead of an error page.
  2. CLOSED → OPEN. Call 3 is the third consecutive failure, so #onFailure trips the breaker and records #openedAt = 0. The state printed after it is OPEN.
  3. OPEN. Call 4 (t = 0) and call 5 (t = 4,000) are inside the 10,000 ms cool-down, so they throw CircuitOpenError immediately. calls stays at 3: the sick service gets no traffic, and these users get the fallback with no timeout wait at all.
  4. OPEN → HALF_OPEN → OPEN. At t = 10,000 two requests start together. "probe 1" sees the cool-down has passed, switches to HALF_OPEN, sets #probing and calls the service (calls → 4). "call 6" arrives while the probe is awaiting its network round trip, finds a probe in flight and fails fast — it prints first, and you can see the transient HALF_OPEN state. The probe then fails (still unhealthy), so the breaker reopens with #openedAt = 10000.
  5. At t = 15,000 the service has recovered, but call 7 is inside the new cool-down (10,000 + 10,000 = 20,000), so it still fails fast. A breaker reacts to recovery only at the next probe, which is the cost of protecting the service.
  6. HALF_OPEN → CLOSED. At t = 20,000 "probe 2" goes through (calls → 5), succeeds, and #onSuccess resets the count and closes the breaker. Call 8 flows normally (calls → 6). Over the whole outage the service received 4 calls instead of 9, and every user got an answer.
AdvancedFallbacks and opossum

Fallbacks

My notes list "fallback mechanism (cached)". A fallback is what you return when the real call fails or the breaker is open: the last good value from a cache, a default ("popular this week" instead of personalised recommendations), a degraded feature (hide the reviews widget), or a queued request ("we'll email you the receipt"). The rule is only fall back where stale or partial data is acceptable to the business. Stale recommendations are fine; a stale account balance, a cached "payment succeeded" or an authorization decision from a cache that should have been revoked is not — there, failing clearly is the correct behaviour.

In production, opossum is the common Node circuit-breaker library (illustrative; assumes it's installed):

opossum-breaker.mjs
import CircuitBreaker from "opossum";

const breaker = new CircuitBreaker(fetchRecommendations, {
  timeout: 1_000, // a call slower than this counts as a failure
  errorThresholdPercentage: 50, // open when half the calls in the rolling window fail
  resetTimeout: 10_000, // cool-down before a half-open probe
});
breaker.fallback(() => cache.get("recommendations:popular"));
breaker.on("open", () => logger.warn("recommendations breaker opened"));

const recs = await breaker.fire(userId);
What’s happening
  1. new CircuitBreaker(fn, options) wraps an async function. timeout plays the role of timeoutMs above: a slow call is a failed call.
  2. errorThresholdPercentage opens on a failure rate over a rolling window rather than a fixed count, which behaves better under real traffic: 3 failures out of 3 calls and 3 out of 10,000 are very different signals.
  3. resetTimeout is the cool-down before the half-open probe, like resetMs.
  4. breaker.fallback(fn) registers what to return when a call fails or the breaker is open, and breaker.fire(...args) calls through the breaker. Listening for open/close events and exporting them as metrics tells you which dependency is sick.

#Data ownership and distributed transactions

In a microservice design, each service owns its data. The order service is the only thing that reads or writes the orders tables; anyone else who needs order data asks through its API or listens to its events. If five services share one schema and write each other's tables, a column rename in "orders" breaks four other teams' deploys — they're deployed separately, but they're still one system (a distributed monolith).

The consequence is the hard part: no single database transaction can cover a business operation that spans services. Placing an order touches the order DB, the inventory DB and the payment provider. Two-phase commit (2PC) exists, but it needs every participant to support it, blocks everyone if the coordinator dies mid-way, and isn't available across HTTP APIs and most brokers, so it's rarely used between services. Instead you use a saga: a sequence of local transactions, each in one service, where every step that can be undone has a compensating action.

The mental model is booking a trip. You book the flight, then the hotel, then the car — three companies, three separate bookings. If the car is unavailable, there's no "rollback" button across companies: you cancel the hotel and the flight, which are new actions with their own rules (maybe a fee). Compensation is a business operation, not an undo.

saga.mjs
// Each "service" owns its own data. No transaction spans them.
const orders = new Map();
const stock = { "sku-1": 5 };
const reservations = new Map();

const orderService = {
  create: async (o) => orders.set(o.id, { ...o, status: "PENDING" }),
  approve: async (id) => (orders.get(id).status = "APPROVED"),
  reject: async (id) => (orders.get(id).status = "REJECTED"),
};
const inventoryService = {
  reserve: async (o) => {
    if (stock[o.sku] < o.qty) throw new Error("out of stock");
    stock[o.sku] -= o.qty;
    reservations.set(o.id, o.qty);
  },
  release: async (o) => {
    stock[o.sku] += reservations.get(o.id) ?? 0;
    reservations.delete(o.id);
  },
};
const paymentService = {
  charge: async (o) => {
    if (o.card === "declined") throw new Error("card declined");
  },
};

async function placeOrderSaga(order) {
  const steps = [
    { name: "create order", run: () => orderService.create(order), undo: () => orderService.reject(order.id) },
    { name: "reserve stock", run: () => inventoryService.reserve(order), undo: () => inventoryService.release(order) },
    { name: "charge card", run: () => paymentService.charge(order), undo: null },
    { name: "approve order", run: () => orderService.approve(order.id), undo: null },
  ];
  const done = [];
  for (const step of steps) {
    try {
      await step.run();
      done.push(step);
      console.log(`  ✓ ${step.name}`);
    } catch (err) {
      console.log(`  ✗ ${step.name}: ${err.message} — compensating`);
      for (const prev of done.reverse()) {
        if (prev.undo) {
          await prev.undo();
          console.log(`  ↩ undo ${prev.name}`);
        }
      }
      return "FAILED";
    }
  }
  return "OK";
}

for (const order of [
  { id: "A", sku: "sku-1", qty: 2, card: "ok" },
  { id: "B", sku: "sku-1", qty: 2, card: "declined" },
]) {
  console.log(`order ${order.id}:`);
  console.log(`  => ${await placeOrderSaga(order)}`);
}
console.log({ orders: Object.fromEntries([...orders].map(([id, o]) => [id, o.status])), stock });

// order A:
//   ✓ create order
//   ✓ reserve stock
//   ✓ charge card
//   ✓ approve order
//   => OK
// order B:
//   ✓ create order
//   ✓ reserve stock
//   ✗ charge card: card declined — compensating
//   ↩ undo reserve stock
//   ↩ undo create order
//   => FAILED
// { orders: { A: 'APPROVED', B: 'REJECTED' }, stock: { 'sku-1': 3 } }
What’s happening
  1. Three "services" each own their state: orders, stock + reservations, and the payment provider. placeOrderSaga is an orchestrator: one component that knows the steps and calls each service in turn. Each step pairs a run with an undo (its compensation), or null when there's nothing to undo.
  2. Order A: the order is created as PENDING, stock goes 5 → 3, the card is charged, the order is approved. Every step succeeded, so done holds all four and nothing is compensated.
  3. Order B: create (PENDING), reserve (stock 3 → 1), then charge throws "card declined". The orchestrator walks the completed steps in reverse — reserve, then create — and runs their compensations.
  4. release puts the 2 units back (1 → 3) using the recorded reservation, and reject marks order B REJECTED. Note it doesn't delete the order: compensation leaves a business record of what happened ("rejected: card declined"), which an undo-style rollback never would.
  5. Final state: A approved, B rejected, stock 3 (only A's 2 units are gone). The system is consistent again — but between the reserve and the release, other requests could see stock 1 and B as PENDING. Sagas give up the isolation of a real transaction; you design for it with explicit pending states.
  6. A real orchestrator persists its progress (which steps are done) so it can resume after a crash, retries each step and each compensation, and needs every step to be idempotent, because a retried step may already have happened.
AdvancedChoreography instead of orchestration

The alternative to an orchestrator is choreography: no central coordinator; each service reacts to events (OrderCreated → inventory reserves and emits StockReserved → payment charges and emits PaymentFailed → inventory releases…). It's more decoupled but harder to follow, because the workflow exists only as the sum of everyone's subscriptions. Orchestration is easier to reason about for flows with many steps; workflow engines such as Temporal make orchestrated sagas durable.

AdvancedThe dual-write problem and the outbox

Every step above has a hidden trap. When the order service saves an order and then publishes OrderCreated to a broker, it's writing to two systems. Crash between them and the order exists with no event (inventory never hears about it), or — if you publish first — the event exists for an order that was never saved. The transactional outbox removes the gap: write the event into an outbox table in the same local transaction as the order, and let a separate relay publish rows from that table.

outbox.mjs
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE orders (id TEXT PRIMARY KEY, total INTEGER);
  CREATE TABLE outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT UNIQUE, payload TEXT, published_at INTEGER);
`);

function createOrder(id, total) {
  db.exec("BEGIN");
  try {
    db.prepare("INSERT INTO orders VALUES (?, ?)").run(id, total);
    db.prepare("INSERT INTO outbox (event_id, payload) VALUES (?, ?)").run(
      `order-created-${id}`,
      JSON.stringify({ type: "OrderCreated", id, total }),
    );
    db.exec("COMMIT"); // the order and its event become visible together, or not at all
  } catch (err) {
    db.exec("ROLLBACK");
    throw err;
  }
}

// The relay runs separately (a loop or a CDC tool) and publishes whatever is pending.
const broker = [];
function relayOnce() {
  const pending = db.prepare("SELECT seq, event_id, payload FROM outbox WHERE published_at IS NULL ORDER BY seq").all();
  for (const row of pending) {
    broker.push({ eventId: row.event_id, ...JSON.parse(row.payload) }); // publish (and wait for the broker's ack)
    db.prepare("UPDATE outbox SET published_at = ? WHERE seq = ?").run(Date.now(), row.seq);
  }
  return pending.length;
}

createOrder("o1", 50);
createOrder("o2", 20);
console.log("published:", relayOnce(), "->", broker.map((e) => e.eventId));
console.log("published:", relayOnce());

// published: 2 -> [ 'order-created-o1', 'order-created-o2' ]
// published: 0
What’s happening
  1. createOrder inserts the order row and an outbox row inside one BEGIN … COMMIT. Either both are committed or, on any error, ROLLBACK removes both. There's no moment where the order exists without its event — the dual write has become a single local write.
  2. The event gets a stable event_id (order-created-o1) at the moment the order is created. That ID is what consumers will deduplicate on.
  3. relayOnce is the relay: it reads unpublished rows in seq order, publishes each, then stamps published_at. The first run publishes both events.
  4. The second run finds nothing with published_at IS NULL and publishes 0. In production the relay polls on an interval, or a change-data-capture tool (e.g. Debezium) tails the database log instead of polling.
  5. If the relay crashes after publishing but before the UPDATE, the next run publishes that event again. The outbox gives you at-least-once publishing, never at-most-once — which is why consumers are idempotent on eventId.

#Securing service-to-service traffic with mTLS

In ordinary TLS (every HTTPS site), only the server proves its identity: it presents a certificate, and the client checks that a certificate authority (CA) it trusts signed it for that hostname. The client stays anonymous at the TLS level and proves who it is later, with a password or token.

Mutual TLS (mTLS) makes the client present a certificate too. Both sides check the other's certificate against a CA they trust — usually your own internal CA, not a public one. You get an encrypted channel and a verified identity for both ends: the inventory service knows the caller is the order service before a single HTTP byte is read. It's the basis of "zero trust" networking: don't assume traffic is safe just because it's inside your network.

The mental model is a secure building where both the visitor and the guard show ID badges issued by the same security office. The guard won't let in someone without a valid badge — but a valid badge only says who you are. Which rooms you may enter is a separate list.

First, a tiny private CA and two certificates signed by it, using the openssl CLI:

gen-certs.sh
# 1. A private CA that both services trust
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=demo-internal-ca" \
  -keyout ca.key -out ca.crt
# 2. The server's certificate, signed by the CA (SAN must match the host the client dials)
openssl req -newkey rsa:2048 -nodes -subj "/CN=inventory" -keyout server.key -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -days 1 -out server.crt \
  -extfile <(printf "subjectAltName=DNS:localhost")
# 3. The client's certificate, signed by the same CA
openssl req -newkey rsa:2048 -nodes -subj "/CN=order-service" -keyout client.key -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -days 1 -out client.crt
What’s happening
  1. Step 1 makes a self-signed CA: a key pair (ca.key, which must stay secret) and a certificate (ca.crt) that services are told to trust. Anything this key signs is "one of ours".
  2. Step 2 creates the inventory service's key and a certificate signing request, then has the CA sign it. The subjectAltName=DNS:localhost matters: clients check the hostname they dialled against the certificate's SAN, not its CN, and refuse a mismatch.
  3. Step 3 does the same for the order service's client certificate. Its CN=order-service is the identity the server will read. (-days 1 keeps this demo throwaway; real internal certificates are often valid for hours or days and rotated automatically.)
Advancedmtls.mjs: mutual TLS in Node
mtls.mjs
import https from "node:https";
import { readFileSync } from "node:fs";

const read = (f) => readFileSync(new URL(`./certs/${f}`, import.meta.url));
const ca = read("ca.crt");

const server = https.createServer(
  {
    key: read("server.key"),
    cert: read("server.crt"),
    ca, // trust client certs signed by our internal CA
    requestCert: true, // ask the client for a certificate
    rejectUnauthorized: true, // and refuse the handshake without a valid one
  },
  (req, res) => {
    const caller = req.socket.getPeerCertificate().subject.CN;
    // Authentication is done; authorization is still our job.
    const allowed = { "order-service": ["POST /reserve"] };
    const action = `${req.method} ${req.url}`;
    if (!allowed[caller]?.includes(action)) {
      res.writeHead(403).end(`${caller} may not ${action}\n`);
      return;
    }
    res.end(`reserved for ${caller}\n`);
  },
);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();

function call(method, path, clientCert) {
  return new Promise((resolve) => {
    const req = https.request({ host: "localhost", port, method, path, ca, ...clientCert }, (res) => {
      let body = "";
      res.on("data", (c) => (body += c));
      res.on("end", () => resolve(`${res.statusCode} ${body.trim()}`));
    });
    req.on("error", (err) => resolve(`handshake failed: ${err.code ?? err.message}`));
    req.end();
  });
}

const orderService = { key: read("client.key"), cert: read("client.crt") };
console.log("no client cert: ", await call("POST", "/reserve"));
console.log("order-service:  ", await call("POST", "/reserve", orderService));
console.log("wrong action:   ", await call("DELETE", "/stock", orderService));
server.close();

// no client cert:  handshake failed: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED
// order-service:   200 reserved for order-service
// wrong action:    403 order-service may not DELETE /stock
What’s happening
  1. The server is a normal https server plus three options: ca (which CA to trust for client certificates), requestCert: true (ask the client for one during the handshake) and rejectUnauthorized: true (abort the handshake if the client has none, or one the CA didn't sign).
  2. The first call sends no client certificate. The TLS 1.3 handshake fails before any HTTP happens: the server sends a "certificate required" alert and the client sees ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED. The request handler never runs — an unauthenticated caller can't even reach your routing code.
  3. On the client side, ca tells Node to trust the internal CA for the server's certificate; without it the call would fail with a self-signed-certificate error. That's the "normal TLS" half: the client verified it's really talking to inventory at localhost.
  4. The second call presents client.crt and client.key. The handshake succeeds, and req.socket.getPeerCertificate().subject.CN is "order-service" — a cryptographically verified identity, unlike an x-service-name header anyone could set.
  5. The third call uses the same valid certificate for DELETE /stock and gets 403. mTLS answered who is calling; the allowed table answers what they may do. Being inside the trusted network with a valid certificate doesn't grant every action.
AdvancedService meshes handle mTLS for you

In Kubernetes you rarely write this yourself. A service mesh (Istio, Linkerd) runs a proxy next to each pod that does mTLS for you, issues short-lived certificates per service identity and rotates them automatically; your Node app talks plain HTTP to its local proxy. The mesh can enforce "only order-service may call inventory" too, but per-request rules ("may this user cancel this order?") stay in the application.

AdvancedPitfall: disabling certificate checks

#Design patterns in Node: factory and dependency injection

The JavaScript site covers both patterns in general — the factory pattern (the caller asks for what, the factory decides how to build it) and dependency injection (a class receives its dependencies instead of creating them, like a lamp with a plug). This topic is about how they show up in a Node service.

My notes' examples are the classic pair: a DatabaseFactory.createDatabase(process.env.DB_TYPE || 'mongodb') that returns a MongoDB or MySQL object with a shared connect() method, and a NotificationService that receives an EmailService or SMSService in its constructor. In a Node service the two fit together:

  • Factories are driven by configuration, read once at start-up — which storage driver, which payment provider, which mailer, depending on process.env. Unknown or missing configuration should fail at boot, not fall back silently (my notes' || 'mongodb' would happily connect a misconfigured production pod to the wrong database type).
  • There's one composition root — usually server.js or a buildApp() function — where config is read and real implementations are created and plugged into the services. Nothing else reads process.env or calls new PostgresClient().
  • Everything below the root receives its dependencies — repositories, clocks, ID generators, loggers, HTTP clients — as arguments. Tests build the same services with fakes, with no module mocking.
orders.mjs
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";

// Two implementations of the same shape: save(order) and findById(id).
function memoryOrderRepo() {
  const rows = new Map();
  return {
    save: async (order) => void rows.set(order.id, structuredClone(order)),
    findById: async (id) => rows.get(id) ?? null,
  };
}

function sqliteOrderRepo(db) {
  db.exec("CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, body TEXT NOT NULL)");
  const insert = db.prepare("INSERT INTO orders (id, body) VALUES (?, ?)");
  const select = db.prepare("SELECT body FROM orders WHERE id = ?");
  return {
    save: async (order) => void insert.run(order.id, JSON.stringify(order)),
    findById: async (id) => {
      const row = select.get(id);
      return row ? JSON.parse(row.body) : null;
    },
  };
}

// Factory: configuration picks the implementation, once, at start-up.
export function createOrderRepo(env) {
  switch (env.ORDER_STORE) {
    case "sqlite":
      return sqliteOrderRepo(new DatabaseSync(env.SQLITE_PATH ?? ":memory:"));
    case "memory":
      return memoryOrderRepo();
    default:
      throw new Error(`ORDER_STORE must be "sqlite" or "memory", got ${JSON.stringify(env.ORDER_STORE)}`);
  }
}

// The business logic receives everything it depends on.
export function createOrderService({ repo, clock, newId }) {
  return {
    async place(customerId, items) {
      if (items.length === 0) throw new Error("an order needs at least one item");
      const order = { id: newId(), customerId, items, placedAt: clock().toISOString() };
      await repo.save(order);
      return order;
    },
    get: (id) => repo.findById(id),
  };
}

// Composition root: the only place that reads env and picks real implementations.
export function buildApp(env = process.env) {
  const repo = createOrderRepo(env);
  return { orders: createOrderService({ repo, clock: () => new Date(), newId: randomUUID }) };
}

if (import.meta.main) {
  const app = buildApp();
  const order = await app.orders.place("c-1", [{ sku: "SKU-1", qty: 2 }]);
  console.log("stored via", process.env.ORDER_STORE, "->", await app.orders.get(order.id));
}

// $ ORDER_STORE=sqlite node orders.mjs
// stored via sqlite -> { id: 'dea200e0-…', customerId: 'c-1', items: [ { sku: 'SKU-1', qty: 2 } ], placedAt: '2026-10-07T18:52:33.674Z' }
// $ node orders.mjs
// Error: ORDER_STORE must be "sqlite" or "memory", got undefined
What’s happening
  1. memoryOrderRepo and sqliteOrderRepo share a shape — save(order) and findById(id), both async — but nothing else. The SQLite one prepares its statements once when it's created, which is the usual Node pattern: build expensive things at start-up, reuse them per request. (The memory repo stores a structuredClone so callers can't mutate stored data by accident, which is what a real database gives you for free.)
  2. createOrderRepo(env) is the factory. It takes the environment as a parameter rather than reading process.env directly, so a test can pass { ORDER_STORE: "memory" }. An unknown value throws with a message naming the setting — the process dies at boot with a clear error, instead of serving traffic with the wrong backend.
  3. createOrderService({ repo, clock, newId }) is plain constructor injection (a factory function here instead of a class). It never imports a database, never calls new Date() or randomUUID() itself — the things that make output unpredictable or need infrastructure are all passed in.
  4. buildApp is the composition root: it reads config, calls the factory, and supplies the real clock and randomUUID. In an Express app it would also build the routers with their services, e.g. createOrdersRouter({ orders }).
  5. import.meta.main is true only when this file is the entry point (node orders.mjs), so importing it from a test doesn't run the demo. With ORDER_STORE=sqlite the order round-trips through SQLite; with no setting the factory refuses to start.
AdvancedTesting with injected fakes

The payoff is in the tests. The built-in node:test runner (see The built-in node:test runner) can exercise the real business logic with fakes:

orders.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { createOrderService, createOrderRepo } from "./orders.mjs";

test("place() stamps the order with the injected clock and id", async () => {
  const saved = [];
  const service = createOrderService({
    repo: { save: async (o) => saved.push(o), findById: async () => null }, // a fake
    clock: () => new Date("2026-01-01T09:00:00Z"),
    newId: () => "order-1",
  });

  const order = await service.place("c-1", [{ sku: "SKU-1", qty: 2 }]);

  assert.deepEqual(order, {
    id: "order-1",
    customerId: "c-1",
    items: [{ sku: "SKU-1", qty: 2 }],
    placedAt: "2026-01-01T09:00:00.000Z",
  });
  assert.equal(saved.length, 1);
});

test("both repos behave the same", async () => {
  for (const ORDER_STORE of ["memory", "sqlite"]) {
    const repo = createOrderRepo({ ORDER_STORE });
    await repo.save({ id: "x", items: [] });
    assert.deepEqual(await repo.findById("x"), { id: "x", items: [] }, ORDER_STORE);
    assert.equal(await repo.findById("missing"), null, ORDER_STORE);
  }
});

// $ node --test orders.test.mjs
// ✔ place() stamps the order with the injected clock and id (1.2ms)
// ✔ both repos behave the same (0.4ms)
// ℹ tests 2 … ℹ pass 2 … ℹ fail 0
What’s happening
  1. The first test builds the service with a hand-written fake repo (an object literal that records what's saved), a frozen clock and a fixed ID generator. No database, no jest.mock, no fake timers.
  2. Because time and IDs are injected, the result is fully predictable, so the test can deepEqual the whole order — including placedAt: "2026-01-01T09:00:00.000Z" (toISOString always prints milliseconds). With new Date() hard-coded inside place, you could only check that some date was set.
  3. saved.length === 1 proves the service actually persisted the order through whatever repo it was given.
  4. The second test is a contract test: it runs the same assertions against both real implementations the factory can return. That's what makes the fakes and the in-memory repo trustworthy stand-ins — if the SQLite repo ever returned undefined instead of null for a missing ID, this test would catch it.
  5. Compare this with the module-singleton style (import db from "./db.js" at the top of every file): the dependency is hidden in the module cache (see Shared state and the singleton pattern), so tests must replace modules with jest.mock (see Mocking modules and dependencies) and every test shares the same instance.
AdvancedDI containers: NestJS, Awilix, InversifyJS

Frameworks add a DI container that wires this up automatically: NestJS (decorators and modules, like Angular), or libraries such as Awilix and InversifyJS. In a plain Express or Fastify service, a hand-written buildApp() is usually all the container you need — it's explicit, and "go to definition" still works.

#SQL vs NoSQL

My notes have this as a heading only ("when to choose one over the other"), so here's the full picture.

Relational (SQL) databases — PostgreSQL, MySQL, SQLite, SQL Server — store data in tables with a schema the database enforces: types, NOT NULL, foreign keys, CHECK constraints, unique indexes. You normalise data (each fact stored once) and use joins to combine it at query time. SQL lets you ask questions nobody planned for when the schema was designed, and transactions are ACID across any rows and tables. They traditionally scale up (a bigger machine) plus read replicas; sharding across machines is possible but is extra work.

NoSQL is a family, not one thing: document stores (MongoDB, Firestore) keep JSON-like documents; key-value stores (Redis, DynamoDB) look things up by key; wide-column stores (Cassandra) are built for huge write volumes; graph databases (Neo4j) for relationship traversal. The common thread is that you model data around your access patterns — store together what you read together — and most are built to partition data across many machines by a key from the start.

A mental model: a relational database is a library catalogue — every book, author and subject stored once and cross-referenced, so you can answer any question by following the references. A document database is a set of packed lunch boxes — each box holds everything for one meal, so grabbing one is instant, but "how many apples are there across all boxes?" means opening every box.

sql-vs-doc.mjs
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
  CREATE TABLE products  (sku TEXT PRIMARY KEY, title TEXT NOT NULL);
  CREATE TABLE orders    (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id));
  CREATE TABLE order_items (
    order_id INTEGER REFERENCES orders(id), sku TEXT REFERENCES products(sku),
    qty INTEGER NOT NULL, unit_price INTEGER NOT NULL
  );
  INSERT INTO customers VALUES (1, 'Rohit'), (2, 'Neha');
  INSERT INTO products VALUES ('SKU-1', 'Keyboard'), ('SKU-2', 'Mouse');
  INSERT INTO orders VALUES (10, 1), (11, 2), (12, 1);
  INSERT INTO order_items VALUES (10, 'SKU-1', 1, 4000), (10, 'SKU-2', 2, 1500), (11, 'SKU-2', 1, 1500), (12, 'SKU-2', 3, 1400);
`);

// A question nobody planned for when designing the schema: revenue per product per customer.
console.log(db.prepare(`
  SELECT c.name, p.title, SUM(i.qty * i.unit_price) AS revenue
  FROM order_items i
  JOIN orders o    ON o.id = i.order_id
  JOIN customers c ON c.id = o.customer_id
  JOIN products p  ON p.sku = i.sku
  GROUP BY c.name, p.title
  ORDER BY revenue DESC
`).all().map((r) => ({ ...r })));

// [
//   { name: 'Rohit', title: 'Mouse', revenue: 7200 },
//   { name: 'Rohit', title: 'Keyboard', revenue: 4000 },
//   { name: 'Neha', title: 'Mouse', revenue: 1500 }
// ]
What’s happening
  1. The schema is normalised: a customer's name lives only in customers, a product's title only in products. order_items refers to them by key. Renaming "Mouse" is a one-row update, and the foreign keys document (and, with PRAGMA foreign_keys = ON, enforce) that every item points at a real order and product.
  2. unit_price is deliberately copied into each item row: an order must remember the price at the time of purchase, even after the product's price changes. Normalisation is about not duplicating facts, and "what this customer paid" is a different fact from "what it costs today".
  3. The query joins four tables to answer a question the schema wasn't designed around. For each item row it finds the order, the order's customer and the product, multiplies qty × unit_price, and groups by customer and product.
  4. Rohit's mouse revenue is order 10 (2 × 1500 = 3000) plus order 12 (3 × 1400 = 4200) = 7200, which is why it sorts first. Neha's single mouse is 1500.
  5. The .map((r) => ({ ...r })) only copies each null-prototype row into a normal object so it prints without the [Object: null prototype] prefix.
AdvancedThe same order as a document

The same order as a document: one read by _id returns everything the order page needs, with no joins.

JSON
{
  "_id": 10,
  "customer": { "id": 1, "name": "Rohit" },
  "items": [
    { "sku": "SKU-1", "title": "Keyboard", "qty": 1, "unitPrice": 4000 },
    { "sku": "SKU-2", "title": "Mouse", "qty": 2, "unitPrice": 1500 }
  ]
}
AdvancedChoosing relational vs NoSQL

The trade is visible in the data: the customer's name and the product titles are copied into every order. Reading one order is a single fast lookup, and the document scales out easily by _id. But renaming the customer means updating every order that embeds the name (or accepting old names on old orders), and the revenue-per-customer question above becomes an aggregation pipeline over every order document.

Choose relational when…Choose a NoSQL store when…
Data is relational, and you'll ask new questions of it (reports, admin tools)Access patterns are known and few (get a session by ID, a user's feed, a device's readings)
You need multi-row transactions and constraints (money, inventory, bookings)Each record is naturally self-contained (a document, a cart, a profile)
Correctness and consistency matter more than raw scaleYou need massive write throughput or horizontal scale beyond one machine
The schema is fairly stable, and you want the DB to enforce itThe shape varies a lot between records, or evolves very fast
You're not sure yet (Postgres is a very good default)You need a specialised model: key-value cache (Redis), graph traversal, time series
AdvancedInterview myths about NoSQL
AdvancedConnection pools across pods

In Node specifically, remember that every instance holds its own connection pool. Ten pods × a pool of 20 is 200 connections, which can exceed a Postgres server's limit when you scale out; that's what poolers like PgBouncer (or a managed proxy) are for. And whatever the database, build queries with parameters, never string concatenation — see SQL injection and parameterised queries and NoSQL injection.

#ACID transactions

A transaction groups several reads and writes into one unit that the database treats as all-or-nothing. ACID names the four guarantees a transaction gives you. My notes list the first three; the fourth is Durability:

  • Atomicity — all the transaction's changes happen, or none do. If anything fails mid-way, or the process crashes, the database rolls back to how it was before BEGIN.
  • Consistency — a transaction moves the database from one valid state to another: every constraint (NOT NULL, foreign keys, CHECK, unique indexes) holds at commit, or the transaction is rejected. (This "C" is about your rules and constraints. It's not the same as the "C" in the CAP theorem, which is about replicas agreeing.)
  • Isolation — concurrent transactions don't see each other's half-finished work. How strictly is set by the isolation level.
  • Durability — once COMMIT returns, the change survives a crash or power loss, because it's been written to durable storage (the write-ahead log or journal) first.

The mental model is a bank transfer: "take 30 from Alice, give 30 to Bob". Atomicity means money never vanishes or appears halfway; consistency means no balance goes below zero; isolation means a report running at the same moment never sees the 30 in neither account (or in both); durability means once the app says "transfer complete", a power cut can't undo it.

Node's built-in node:sqlite lets you watch each property happen. Atomicity and consistency first:

acid.mjs
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE accounts (
    name    TEXT PRIMARY KEY,
    balance INTEGER NOT NULL CHECK (balance >= 0)
  );
  INSERT INTO accounts VALUES ('alice', 100), ('bob', 0);
`);

const debit = db.prepare("UPDATE accounts SET balance = balance - ? WHERE name = ?");
const credit = db.prepare("UPDATE accounts SET balance = balance + ? WHERE name = ?");
const balances = () => db.prepare("SELECT name, balance FROM accounts ORDER BY name").all().map((r) => `${r.name}=${r.balance}`).join(" ");

function transfer(from, to, amount, { crashAfterCredit = false } = {}) {
  db.exec("BEGIN");
  try {
    credit.run(amount, to);
    if (crashAfterCredit) throw new Error("process died mid-transfer");
    debit.run(amount, from); // CHECK fails if this would go negative
    db.exec("COMMIT");
    console.log(`transfer ${amount}: committed   -> ${balances()}`);
  } catch (err) {
    console.log(`  inside the transaction before rollback: ${balances()}`);
    db.exec("ROLLBACK");
    console.log(`transfer ${amount}: rolled back (${err.message}) -> ${balances()}`);
  }
}

console.log("start:", balances());
transfer("alice", "bob", 30);
transfer("alice", "bob", 500);
transfer("alice", "bob", 10, { crashAfterCredit: true });

// start: alice=100 bob=0
// transfer 30: committed   -> alice=70 bob=30
//   inside the transaction before rollback: alice=70 bob=530
// transfer 500: rolled back (CHECK constraint failed: balance >= 0) -> alice=70 bob=30
//   inside the transaction before rollback: alice=70 bob=40
// transfer 10: rolled back (process died mid-transfer) -> alice=70 bob=30
What’s happening
  1. The table has a CHECK (balance >= 0) constraint: the rule "no negative balances" lives in the database, so every code path that writes balances is held to it — that's the C.
  2. Transfer 30: BEGIN, credit Bob (0 → 30), debit Alice (100 → 70), COMMIT. Both changes become permanent together.
  3. Transfer 500: the credit runs first and succeeds — the line printed inside the transaction shows Bob at 530, money that doesn't exist yet. Then the debit would take Alice to 70 - 500 = -430, the CHECK rejects it, and run throws CHECK constraint failed: balance >= 0.
  4. The catch runs ROLLBACK, and the half-finished transfer disappears: back to alice=70 bob=30. That's atomicity: the credit that did succeed is undone because its partner failed. Without the transaction (each UPDATE auto-committing), Bob would have kept 500 free money.
  5. Transfer 10 simulates a failure between the two writes. Again the in-transaction view shows the half-done state (bob=40), and again ROLLBACK erases it. Any error between BEGIN and COMMIT — a thrown exception, a failed constraint, a bug — gets the same all-or-nothing result.
  6. Writing the credit before the debit is deliberate, to make the half-done state visible. Real code usually debits first, but atomicity is what makes the order irrelevant.
AdvancedAtomicity after a real crash

A thrown error is a polite failure. Here's a real crash: a separate process opens a transaction, writes, and exits without committing.

crash.mjs
import { DatabaseSync } from "node:sqlite";
import { execFileSync } from "node:child_process";

const file = new URL("./crash.db", import.meta.url).pathname;
const setup = new DatabaseSync(file);
setup.exec(`
  CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, balance INTEGER NOT NULL);
  INSERT OR REPLACE INTO accounts VALUES ('alice', 100);
`); // autocommit: this is committed (durable) as soon as exec returns
setup.close();

// A separate process starts a transaction, writes, and dies before COMMIT.
const child = `
  const { DatabaseSync } = require("node:sqlite");
  const db = new DatabaseSync(${JSON.stringify(file)});
  db.exec("BEGIN");
  db.exec("UPDATE accounts SET balance = balance + 1000 WHERE name = 'alice'");
  console.log("child sees", db.prepare("SELECT balance FROM accounts").get().balance, "then dies");
  process.exit(1);
`;
try {
  execFileSync(process.execPath, ["-e", child], { stdio: "inherit" });
} catch (err) {
  console.log("child exit status:", err.status);
}

const db = new DatabaseSync(file);
console.log("after reopening:", db.prepare("SELECT balance FROM accounts WHERE name = 'alice'").get().balance);

// child sees 1100 then dies
// child exit status: 1
// after reopening: 100
What’s happening
  1. The setup writes Alice's 100 to a file database with no explicit BEGIN, so SQLite wraps the statements in an automatic transaction and commits them before exec returns. That committed value is now durable: it's on disk, not just in memory.
  2. execFileSync runs a second Node process (process.execPath is the current node binary). It opens the same file, starts a transaction and adds 1,000. Inside its transaction it sees 1100.
  3. process.exit(1) kills the child with the transaction still open — no COMMIT, no ROLLBACK, no clean shutdown. execFileSync throws because of the non-zero exit, and the parent prints status: 1.
  4. The parent reopens the file and reads 100. The uncommitted change never became part of the database: SQLite keeps enough in its journal (the original pages in the default rollback-journal mode, or the new pages in WAL mode) to undo or ignore a transaction that didn't commit, and does so on the next open. Atomicity holds even for a crash.
  5. Durability is the other half of the same mechanism: if the child had committed before dying, the 1,100 would have survived, because COMMIT doesn't return until the change has been flushed to disk. (SQLite's PRAGMA synchronous and Postgres's synchronous_commit let you trade some of that guarantee for speed — know you're doing it if you do.)
AdvancedIsolation with two connections

Isolation needs two connections at once. Here connection A updates inside a transaction while connection B — standing in for another process — reads and tries to write:

isolation.mjs
import { DatabaseSync } from "node:sqlite";

const file = new URL("./bank.db", import.meta.url).pathname;
const a = new DatabaseSync(file);
a.exec(`
  PRAGMA journal_mode = WAL;
  CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, balance INTEGER NOT NULL);
  INSERT OR REPLACE INTO accounts VALUES ('alice', 100);
`);
const b = new DatabaseSync(file); // a second connection, like a second process

const read = (conn) => conn.prepare("SELECT balance FROM accounts WHERE name = 'alice'").get().balance;

a.exec("BEGIN");
a.exec("UPDATE accounts SET balance = 0 WHERE name = 'alice'");
console.log("A sees (uncommitted):", read(a));
console.log("B sees:", read(b));
try {
  b.exec("UPDATE accounts SET balance = 999 WHERE name = 'alice'");
} catch (err) {
  console.log("B write:", err.message, `(errcode ${err.errcode})`);
}
a.exec("COMMIT");
console.log("B sees after A commits:", read(b));
a.close();
b.close();

// A sees (uncommitted): 0
// B sees: 100
// B write: database is locked (errcode 5)
// B sees after A commits: 0
What’s happening
  1. PRAGMA journal_mode = WAL switches SQLite to write-ahead logging, where readers keep reading the last committed state while a writer works. Two DatabaseSync objects on the same file are two independent connections, just like two processes.
  2. A begins a transaction and sets Alice's balance to 0. A sees its own uncommitted change: 0.
  3. B reads 100. A's change isn't committed, so B can't see it — there's no dirty read. If A rolled back now, nobody would ever have acted on a balance that never really existed.
  4. B tries to write the same row. SQLite allows one writer at a time, and A holds the write lock, so B fails with database is locked (errcode 5 is SQLite's SQLITE_BUSY). It fails immediately because node:sqlite's busy timeout defaults to 0; pass new DatabaseSync(file, { timeout: 5000 }) to wait up to 5 s for the lock instead. Postgres and MySQL lock at row level, so there B would block on that one row until A finishes.
  5. A commits, and now B reads 0. Committed changes become visible to new reads; uncommitted ones never leak. SQLite's transactions behave as serializable — the strictest level — partly because it allows only one writer at a time.
AdvancedIsolation levels

Client-server databases let many transactions write at once, and the isolation level decides which anomalies you accept in exchange for less locking:

LevelDirty readNon-repeatable readPhantom readNotes
Read uncommittedpossiblepossiblepossibleRarely used; Postgres treats it as read committed
Read committedpreventedpossiblepossiblePostgres default: each statement sees data committed before it began
Repeatable readpreventedpreventedpossible in the SQL standardMySQL InnoDB default; Postgres's version also prevents phantoms
SerializablepreventedpreventedpreventedAs if transactions ran one at a time; may abort with a serialization error you must retry

A dirty read sees another transaction's uncommitted data. A non-repeatable read reads the same row twice in one transaction and gets different values because someone committed in between. A phantom re-runs a query and gets new rows that someone inserted. The one that bites Node developers most often, though, is the lost update — and it doesn't need threads:

lost-update.mjs
import { DatabaseSync } from "node:sqlite";
import { setTimeout as sleep } from "node:timers/promises";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER NOT NULL CHECK (qty >= 0)); INSERT INTO stock VALUES ('SKU-1', 10);");
const qty = () => db.prepare("SELECT qty FROM stock WHERE sku = 'SKU-1'").get().qty;

// Read, think (await), write back: two requests interleave at the await.
async function buyNaive(n) {
  const current = qty();
  await sleep(10); // e.g. calling the pricing service
  db.prepare("UPDATE stock SET qty = ? WHERE sku = 'SKU-1'").run(current - n);
}
await Promise.all([buyNaive(3), buyNaive(4)]);
console.log("naive:", qty(), "(expected 3)");

// Let the database do the read-modify-write in one atomic statement, with a guard.
db.exec("UPDATE stock SET qty = 10");
async function buyAtomic(n) {
  await sleep(10);
  const { changes } = db.prepare("UPDATE stock SET qty = qty - ? WHERE sku = 'SKU-1' AND qty >= ?").run(n, n);
  return changes === 1 ? `bought ${n}` : `not enough for ${n}`;
}
console.log(await Promise.all([buyAtomic(3), buyAtomic(4), buyAtomic(5)]), "-> left:", qty());

// naive: 6 (expected 3)
// [ 'bought 3', 'bought 4', 'not enough for 5' ] -> left: 3
What’s happening
  1. Promise.all starts two purchases concurrently. buyNaive(3) reads current = 10 and hits await sleep(10), which hands control back to the event loop; buyNaive(4) then runs and also reads current = 10. Single-threaded doesn't mean one-request-at-a-time: every await is a point where another request's code runs.
  2. After 10 ms the first resumes and writes 10 - 3 = 7; the second resumes and writes 10 - 4 = 6, overwriting the 7. Final stock is 6 instead of 3 — the first purchase's update is lost, and you've sold 7 units while only recording 4.
  3. The fix moves the read-modify-write into one SQL statement: SET qty = qty - ? computes from the row's current value at the moment of the write, inside the database, so there's no gap for another request to slip into.
  4. AND qty >= ? turns it into a conditional update. The three purchases apply in turn: 10 → 7 (bought 3), 7 → 3 (bought 4), then 3 >= 5 is false, so zero rows change and the code reports not enough for 5. changes tells you whether your condition held.
  5. In Postgres the same patterns apply: an atomic UPDATE … SET qty = qty - $1 WHERE … AND qty >= $1, or SELECT … FOR UPDATE inside a transaction to lock the row while you decide, or an optimistic version column (UPDATE … WHERE id = $1 AND version = $2) that fails if someone else wrote first.

With a client-server database the transaction must run on one connection. With the pg driver (illustrative; assumes pg installed and a Postgres database):

pg-transfer.mjs
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

export async function transfer(from, to, amount) {
  const client = await pool.connect(); // check out ONE connection for the whole transaction
  try {
    await client.query("BEGIN");
    const { rowCount } = await client.query(
      "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
      [amount, from],
    );
    if (rowCount === 0) throw new Error("insufficient funds");
    await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release(); // always return the connection to the pool
  }
}
What’s happening
  1. pool.connect() checks out a single connection. Everything between BEGIN and COMMIT must go through that client, because a transaction belongs to a connection.
  2. The debit is a conditional atomic update with parameters ($1, $2), so it's safe from both lost updates and SQL injection. rowCount === 0 means the guard failed — insufficient funds or no such account — and throwing sends us to ROLLBACK.
  3. Any error, including the credit failing or a network error, runs ROLLBACK, then rethrows so the caller knows the transfer didn't happen.
  4. finally { client.release() } returns the connection whether the transaction succeeded or not. Forget it on one error path and each failure leaks a connection; after max (10) leaks, every pool.connect() waits forever and the service hangs.
  5. The classic bug is calling pool.query("BEGIN"), then pool.query("UPDATE …"). Each pool.query may run on a different connection, so the BEGIN opens a transaction on one connection and the updates auto-commit on others — no atomicity at all, and a connection left idle in an open transaction.
Where did the transaction go?
JavaScript
await pool.query("BEGIN");
await pool.query("UPDATE accounts SET balance = balance - 30 WHERE id = 1");
await pool.query("UPDATE accounts SET balance = balance + 30 WHERE id = 2");
await pool.query("COMMIT");

The second UPDATE fails (say, a constraint). Is account 1 still debited?

Show answer

It may well be. The four calls aren't one transaction.

What’s happening
  1. pool.query checks out some idle connection, runs the query and releases it. With a pool of several connections, consecutive calls can land on different ones.
  2. BEGIN might run on connection 1. The first UPDATE might run on connection 2, where no transaction is open, so it auto-commits immediately: account 1 is debited for good.
  3. The second UPDATE fails on whichever connection it got. There's nothing to roll back the debit, because it was never part of a transaction.
  4. Meanwhile connection 1 is sitting inside an open transaction that nobody will finish (until COMMIT happens to land on it). Under load it's all nondeterministic, which is why this bug survives testing on a quiet machine. Use const client = await pool.connect() and run every statement of the transaction on that client, as in pg-transfer.mjs.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.