Backend Engineering — Quick Summary
Quick revision: every backend topic with a full mental model — HTTP & APIs, auth & security, databases, caching, scaling, messaging, and reliability, in one printable doc.
This is a quick revision doc covering all 42 topics in the Backend Engineering collection. Each entry gives a full mental model on its own — read this as a standalone pass before an interview. Open the linked notes only if you want even more depth or extra code.
Web & Networking Foundations
How the Web Works — Request Lifecycle
“What happens when you type a URL and hit enter?” is the classic warm-up, and the honest answer is a relay race that runs before a single byte of the page appears — and the same relay runs whether it’s a browser loading HTML or our mobile app calling a JSON API. First, the model: the web is client-server. A client (browser, app, curl) always starts the conversation; a server listens, does the work, and answers. Servers never tap us on the shoulder out of the blue over plain HTTP. Think ordering at a restaurant — we ask the waiter, the kitchen cooks, we don’t wander into the kitchen ourselves.
Now the skeleton, and it’s worth memorizing in order: DNS → TCP → TLS → HTTP → server → response → render. Step 1, DNS resolution turns a name like api.shop.com into an IP address, because machines route on IPs not names. Our box checks caches first (browser → OS → router) and only on a miss asks a resolver that walks the hierarchy (root → .com → shop.com); the answer carries a TTL saying how long we may cache it, which is why DNS changes take time to spread. Step 2, the TCP handshake (SYN → SYN-ACK → ACK) opens a reliable pipe to that IP on port 443 — the “you there? yep. great.” bit. Step 3, because it’s https://, the TLS handshake verifies the server’s certificate and agrees on secret keys so nobody snooping the wire can read us; it happens right after TCP and before any HTTP. Step 4, the actual HTTP request goes out — a plain-text method, path, headers, maybe a body.
Step 5 is our world: the backend does routing (match /orders to a handler), auth (valid token? allowed?), business logic, data access (DB or cache, other services), then serializes the result to JSON. Steps 6 and 7, the server returns a status code, headers, and body; the client reads the status and renders. And note the first HTML response often references CSS, JS, and images — each firing its own mini lifecycle, so one page load is dozens of these.
HTTP Deep Dive
HTTP is the request-response language clients and servers speak — “here’s what I want” and “here’s what you get”. Every message has the same shape: a start line, headers, a blank line, then an optional body; the response mirrors it with a status line instead. The methods are verbs: GET reads (no body, changes nothing), POST creates or runs an action, PUT replaces a resource entirely, PATCH partially updates just the fields we send, DELETE removes. Two words interviewers poke at: safe means no server-state change (GET), and idempotent means calling once or ten times lands the same final state. GET, PUT, DELETE are idempotent; POST is not — POST twice and you may create two orders, which is exactly why double-clicking “Buy” is dangerous.
Status codes go by first digit: 1xx hold on, 2xx worked, 3xx go elsewhere, 4xx you messed up, 5xx we messed up.
Location) · 204 No ContentThe trick pairs: 401 vs 403 (401 = “who are you?”, 403 = “I know you, no”), and 502 vs 503 (502 = the server behind me is broken, 503 = I’m alive but can’t serve). Headers worth knowing: Content-Type (what the body is), Accept (what the client wants back), Authorization (Bearer <token>), Cache-Control, and ETag — a resource fingerprint the client echoes as If-None-Match; if it still matches, the server returns 304 and skips resending the body. Finally the versions, same semantics different plumbing: HTTP/1.1 does one request at a time per connection (app-level head-of-line blocking, hacked around with ~6 connections per host); HTTP/2 multiplexes many streams over one TCP connection plus header compression, but a lost packet still stalls all streams at the TCP layer; HTTP/3 moves to QUIC over UDP so streams are truly independent and folds in TLS for faster setup.
HTTPS & TLS
HTTPS is just HTTP with a security layer bolted underneath, and that layer is TLS (Transport Layer Security — the modern name for what people still call SSL). It buys us three guarantees worth memorizing as a trio: encryption (snoopers on coffee-shop wifi or our ISP see gibberish, not our password), integrity (flip one bit in transit and the receiver notices and rejects it), and authentication (we’re really talking to bank.com, not an imposter). Plain HTTP gives none of these, which is why browsers now shame every http:// site as “Not Secure”.
The whole game is understanding the two kinds of crypto. Symmetric uses one shared key to both encrypt and decrypt — fast, but both sides need the same key, and shouting a secret key across the open internet is obviously mad. Asymmetric uses a key pair: anything locked with the public key can only be opened by the matching private key. The public key goes to the world, the private key never leaves the server — slower, but it solves “how do we agree on a secret over an open wire”. Picture asymmetric as an expensive armored truck we use once, and symmetric as the cheap fast car for every trip after. So TLS does both: use slow asymmetric crypto during the handshake only to agree on a shared symmetric session key, then use fast symmetric crypto for the actual data.
The handshake runs right after TCP: ClientHello (my TLS versions, cipher suites, a random) → ServerHello (chosen cipher, its random) → Certificate (server sends its cert containing its public key) → key exchange (using that public key both sides agree a shared secret and derive matching symmetric session keys) → Finished (every byte after is symmetric-encrypted). Modern TLS 1.3 uses an ephemeral Diffie-Hellman here for forward secrecy (past traffic stays safe even if the private key later leaks) and trims the whole thing to one round trip. Why trust the cert? A certificate is a signed ID card saying “this public key belongs to bank.com”, signed by a Certificate Authority. CAs don’t sign your site with the precious root key — the root signs an intermediate, which signs your leaf cert. The browser walks that chain of trust up to a root it already ships with; any broken, expired, or self-signed link gives the scary “not private” page.
TCP vs UDP & Ports
TCP and UDP are the two ways data actually moves across the internet, both riding on IP (which just shuttles packets from A to B). In simple language: TCP is the careful courier that guarantees delivery; UDP is the guy who throws the package over the fence and hopes for the best. TCP is connection-oriented — both sides shake hands first, then it promises three things: reliable (every packet acknowledged, losses retransmitted), ordered (reassembled in send order even if they arrive scrambled), and error-checked (corruption caught and resent). The price is overhead and latency. UDP is connectionless — no handshake, no acks, no reordering; we just fling datagrams and move on, so it’s lean and fast but makes no guarantees, leaving the app to handle loss itself. TCP is a registered letter with tracking; UDP is a postcard.
The rule of thumb: does losing data break things, or does waiting for it break things? Use TCP when every byte matters (a missing chunk of a JSON response is useless); use UDP when speed beats completeness (a dropped video frame from 200ms ago is worthless — skip it and stay live). Fun facts: HTTP/3 runs on UDP via QUIC and rebuilds reliability itself; DNS uses UDP but falls back to TCP for large responses. The 3-way handshake is SYN → SYN-ACK → ACK — after those three packets both sides have agreed starting sequence numbers and the pipe is live (closing is a separate 4-way FIN/ACK dance). Finally, ports and sockets: an IP gets us to the right machine, a port to the right program (443 HTTPS, 80 HTTP, 22 SSH, 53 DNS, 5432 Postgres, 6379 Redis, 3306 MySQL). A socket is the endpoint (IP + port + protocol), and a unique TCP connection is keyed by the four-tuple — source IP, source port, dest IP, dest port — which is how one server on port 443 holds thousands of simultaneous connections.
Real-Time: WebSockets, SSE & Long Polling
Here’s the core problem: plain HTTP is request-response — the client asks, the server answers, done. The server can’t just tap the client and say “new message arrived”, so how do we build live chat, a stock ticker, or a notifications badge? We need a way for the server to push, and there are four approaches, each a step up in real-time-ness.
Short polling is the dumb-but-simple option: the client fires a request on a timer (“anything new?”) every few seconds, and the server usually answers “nope”. It works but it’s wasteful — mostly empty responses, and always lag, since a message landing right after a poll waits for the next one. Long polling is smarter: the client asks, but the server holds the request open until it actually has data (or a timeout), replies the moment there’s something, and the client immediately re-opens. Same request-response model, far fewer empty responses, near-instant delivery — it’s how “real-time” was done before WebSockets and it’s still a solid fallback. SSE (Server-Sent Events) opens one long-lived HTTP connection over which the server streams messages one way, server → client; the client can’t send back on that channel. It’s built into browsers via EventSource, auto-reconnects for us, and rides plain HTTP so proxies and firewalls don’t complain — perfect for feeds, notifications, live scores, progress bars. WebSockets give a full-duplex pipe — both sides send any time, independently, a real two-way phone call instead of walkie-talkie turns. It starts as a normal HTTP request carrying Upgrade: websocket, the server agrees with 101 Switching Protocols, and from then the same TCP connection carries raw WebSocket frames with tiny per-message overhead — no longer HTTP.
Rules of thumb: only the server pushes, one-way? → SSE (simplest, plain HTTP, free reconnect). Both sides chatter constantly — chat, multiplayer, collab editing? → WebSockets. Need a dead-simple fallback? → long polling. Short polling only for low-stakes cases where a few seconds of lag is fine and you want zero moving parts. The direction of data flow is the whole story that tells you which to reach for.
API Design
REST API Design Principles
REST stands for Representational State Transfer — a scary name for a simple idea: we expose our data as resources that clients read and change using plain HTTP. A resource is any “thing” our API talks about (a user, an order, a product), and each one gets its own URL (a URI) — every noun in our system gets an address. We never hand over the raw database row; we hand over a representation of it, usually JSON, though the same resource could be sent as XML or CSV. The resource is the concept, the representation is the format on the wire.
The big pillar is statelessness: every request carries everything the server needs to handle it, and the server keeps no memory of previous requests — no session sitting in server RAM. Why we care: any of our 10 servers behind a load balancer can handle any request, so scaling and failover get easy. The client proves who it is on every request, typically a token in the Authorization header — there’s no “I logged in three requests ago so you know me”.
The single most common mistake is putting verbs in URLs. URLs name things (nouns); the HTTP method already supplies the verb. So DELETE /users/5, never POST /users/5/delete or POST /deleteUser. Lock in a few habits: plural nouns (/users, /users/5 for one item), nest for relationships but only a level or two (/users/5/orders), hyphens and lowercase (/order-items), and no file extensions — let the Accept header pick the format. Use methods as meant: GET reads, POST creates (to the collection, server returns the new URL in a Location header), PUT replaces fully, PATCH tweaks fields, DELETE removes.
The bonus round is HATEOAS (Hypermedia As The Engine Of Application State) — responses embed links (_links) telling the client what it can do next so it doesn’t hardcode URLs. It’s the top of REST maturity, but almost nobody fully does it — know what it is, don’t sweat that your API lacks it.
Takeaway: resources with clean noun URLs + correct HTTP methods + stateless requests + proper status codes = 95% RESTful.
API Error Handling & Status Codes
Happy-path APIs are easy; what separates a pro API from a hobby one is how it behaves when things go wrong. Good error handling means the caller always knows what broke, whose fault it was, and what to do next. Two things do the work: the right status code (the machine-readable signal) and a consistent error body (the developer-readable detail).
The golden rule on codes: 4xx means the client should fix its request; 5xx means the client should retry or wait for us to fix things. The worst offender is returning 200 OK with {"error": "..."} in the body — it lies to every proxy, cache, and monitoring tool watching the response. Two perennial mixups: 401 vs 403 — 401 is “who are you?” (not authenticated, log in), 403 is “I know you, and no” (authenticated but not authorized). And 400 vs 422 — 400 is “I can’t even read this” (broken JSON, missing field, wrong content type), 422 is “I read it fine, but the values fail our business rules” (bad email, negative age).
Every error should share one shape so a frontend never has to guess. A solid minimum carries three load-bearing parts: a stable machine-readable code (clients branch on this, not the wording — we can reword message anytime, code is the contract), a human message, and field-level details.
{ "code": "VALIDATION_ERROR", "message": "One or more fields are invalid.",
"details": [{ "field": "email", "issue": "must be a valid email" }],
"requestId": "req_a1b2c3d4" }
Instead of inventing a shape, there’s a standard — Problem Details for HTTP APIs, RFC 7807 now updated by RFC 9457, served as application/problem+json with type/title/status/detail/instance (and you can add your own fields). Critically, never leak internals: no stack traces, SQL, or file paths to the client — that hands an attacker our framework and DB. Keep the juicy detail in logs, keyed by a correlation / request ID you generate per request (or accept via X-Request-Id), stamp on every log line, and return in every error — so “it failed at 3pm” maps to exact log lines, even across five microservices.
API Versioning, Pagination & Filtering
Once real clients depend on our API we can’t change it on a whim, and once tables hit millions of rows we can’t return everything at once. Versioning lets us change without breaking people; pagination hands out big data in bite-sized pages.
An API is a contract — renaming name to fullName breaks everyone built against it. Versioning is the escape hatch: keep v1 alive while v2 carries the new shape; old clients keep working, new ones opt in. Key nuance to say out loud: additive changes don’t need a new version — a new optional field or endpoint breaks nobody; we only bump for breaking changes (removing/renaming a field, changing a type). Three ways to version: URI path (/v1/users — obvious, testable in a browser, but “purists” hate it), custom header (API-Version: 2 — clean URLs, but invisible and harder to curl), and media type (Accept: application/vnd.api.v2+json — most “RESTful”, but fiddly). Honest take: /v1/ in the path is the pragmatic default because it’s the least surprising.
For paging there are two schools. Offset/limit (?limit=20&offset=40, “skip 40, give me 20”) is dead simple and lets us jump to any page number — but it gets slow at scale (the DB walks and throws away all skipped rows first) and is unstable (an insert mid-paging shifts everything, causing dupes or skips). Cursor/keyset (?limit=20&cursor=…) instead says “give me 20 items after this specific item” — the cursor encodes the last row seen (its id, or created_at+id). The query becomes WHERE id > 123 ORDER BY id LIMIT 20, an index seek that jumps straight there — fast and stable at any depth — at the cost of losing random page jumps (next/previous only). Rule of thumb: offset for small admin-y lists where page numbers matter; cursor for big feeds, infinite scroll, and public APIs.
Filtering and sorting need no new endpoints — they’re just query params on the collection: ?status=shipped&sort=-createdAt&limit=20 (the - means descending). Always whitelist which fields can be filtered/sorted — blindly turning params into SQL is both a performance and a security hole.
Idempotency, Safe Methods & Retries
Networks are flaky — requests time out, connections drop, clients retry. If a retried “charge the customer” runs twice, someone gets billed twice. Idempotency is how we make retries safe: an idempotent operation gives the same result whether we run it once or ten times.
Two words people smush together but shouldn’t. Safe = the request changes nothing on the server (pure reads — GET, HEAD); we can hammer them all day. Idempotent = it can change things, but running it again lands in the same final state. Every safe method is automatically idempotent, but not vice versa. PUT /users/5 {name:"Manish"} is idempotent (send it five times, user 5 is still named Manish). DELETE is idempotent (deleted stays deleted). POST /users is neither — five calls create five users.
That’s the danger. Picture the client sending POST /charges; the server charges the card and starts sending 200 OK… but the connection dies before the response lands. The client has no idea if it worked — retry (maybe double-charge) or give up (maybe never charge)? This is at-least-once delivery: the only way to guarantee a message arrives is being willing to resend it, which makes duplicates inevitable. POST — creating things, charging cards — is exactly where retries are dangerous.
The fix is an idempotency key: the client generates a unique key (a UUID) per operation and sends it in a header. Server logic in plain words: never seen this key? do the work, store key → result, return it. Seen it? skip the work entirely, replay the stored result. So a lost-response retry with the same key charges the card exactly once. This is literally how Stripe works.
POST /charges
Idempotency-Key: 4f8c-6b2a-9d1e-7c3f
{ "amount": 5000, "currency": "usd", "card": "tok_visa" }
Practical notes: the client owns the key (same across retries of one logical op, different for genuinely new ones); store keys with a TTL (~24h); guard the in-flight race with a unique DB constraint or lock so two simultaneous retries don’t both do the work. On the client, retry with exponential backoff + jitter (1s, 2s, 4s, plus randomness so a thousand clients don’t stampede in lockstep), and only auto-retry idempotent ops or key-guarded POSTs.
REST vs GraphQL vs gRPC
“Should this be REST, GraphQL, or gRPC?” is a real work and interview question. They’re three styles of API, each great at a different job — there’s no “best”, only “best for this”.
REST is resources with noun URLs over HTTP — simple, cacheable, works in any browser, understood by everyone; the safe default. Its pain is data shape: each endpoint returns a fixed structure, so we get over-fetching (mobile needs just a name but GET /users/5 ships the whole fat object) and under-fetching (rendering one screen needs a user and orders and items → three or four round trips, the classic chattiness).
GraphQL flips it: one endpoint (POST /graphql), and the client sends a query naming exactly the fields it wants — no more, no less. One round trip, over/under-fetch basically vanish — its superpower, fantastic when web, iOS, and Android each want a different slice of the same data. The catch is on our side: a naive resolver for orders → items fires a fresh DB query per order — the dreaded N+1 query problem (fixed with batching like DataLoader, but real work). It’s also hard to cache (everything’s a POST to one URL, so plain HTTP caching doesn’t apply) and needs a schema/query layer.
gRPC isn’t about resources at all — it’s RPC: we define services and methods in a .proto file and it feels like calling a local function that runs on another machine. Two things make it fast: Protocol Buffers (a compact binary format, much smaller than JSON) over HTTP/2 (multiplexes many calls on one connection, supports streaming — client, server, or bidirectional). Perfect for chatty service-to-service traffic and real-time streams. The trade-off: binary means we can’t eyeball it in a browser or curl it, and browsers can’t call it directly (need a grpc-web proxy) — so gRPC lives mostly inside the datacenter.
Takeaway: default to REST for simple public cacheable APIs, GraphQL when many clients need different shapes of the same data, gRPC for internal high-throughput service-to-service calls and streaming. Real systems mix them — gRPC between backend services, REST or GraphQL at the edge for the outside world.
Authentication & Security
Authentication vs Authorization
These two get mixed up constantly and interviewers love catching us on it. In simple language, authentication (authn) answers “who are you?” and authorization (authz) answers “what are you allowed to do?” Same prefix, totally different jobs, and authn always runs first — we can’t decide what someone’s allowed to do until we know who they are. Think airport: authentication is the passport check (does the face match the photo), authorization is the boarding pass and the guard checking we’re allowed into the business lounge. Same person, different gates. We authenticate with something we know (password, PIN), something we have (phone for OTP, hardware key), or something we are (fingerprint, face) — combine two of those and it’s MFA.
In the request pipeline they sit at different points, and the order drives the two status codes people always trip on. 401 Unauthorized really means unauthenticated — the name is misleading; we don’t know who you are, so the fix is “log in”. 403 Forbidden means we know exactly who you are and you still can’t do this — valid identity, insufficient permission, and logging in again won’t help. So someone hitting an admin route with a good token but a regular-user role is a 403, not a 401. In middleware terms: an authenticate gate verifies the token and returns 401 if it’s bad; a separate requireAdmin gate checks the role and returns 403 if it’s wrong.
Once we’re doing authz, we need a model for who can do what. RBAC (Role-Based Access Control) assigns users roles (admin, editor, viewer) that carry permissions — simple, easy to reason about, where most apps start; the downside is roles explode when rules get granular (“editor, but only for their own team’s docs”). ABAC (Attribute-Based Access Control) decides from attributes of the user, resource, and context (“allow if user.department == resource.department and it’s business hours”) — far more flexible, more complex to build and audit. One line: RBAC checks what role you have, ABAC checks the attributes of you plus the thing you’re touching.
Takeaway: authn first (fail = 401), authz second (fail = 403); RBAC = roles, ABAC = attributes.
Sessions vs JWT
After login, how does the server remember us on the next request? HTTP is stateless — each request arrives with no memory of the last — and there are two big answers. With server-side sessions, the server remembers who’s logged in and the browser just carries a ticket stub. On login the server creates a session record (in memory, Redis, or a DB), generates a random session ID, and returns it in a cookie (Set-Cookie: sid=8f3a...; HttpOnly; Secure; SameSite=Lax). Every later request auto-sends the cookie, the server looks up the session, and knows us. The session ID is meaningless on its own — just a lookup key; all the real data lives on the server.
A JWT (JSON Web Token) flips it: we pack the user info into the token, sign it, and hand it over — the server keeps nothing, which is what “stateless” means. On the next request the client sends it back (Authorization: Bearer ...) and the server just verifies the signature — no DB lookup. It’s a coat-check ticket (session ID, meaningless without the server’s records) versus a signed tamper-proof wristband (JWT, everything printed on it). A JWT is three Base64URL chunks dotted together: header.payload.signature. Two things people get wrong: the payload is Base64, not encrypted — anyone can decode and read it, so never put secrets there; and the signature doesn’t hide anything, it only proves the token wasn’t tampered with, because only the server knows the signing secret.
The catch interviewers dig into is revocation. Because the server keeps no state, it can’t un-issue a token — a stolen or banned user’s JWT stays valid until it expires on its own. Fixes: short-lived access tokens (5–15 min) plus a long-lived refresh token to mint new ones (small blast radius), or a denylist of revoked IDs (but that’s server state again, defeating the point). Sessions have no such problem — delete the row and the user’s instantly out. On storage, pick your poison: localStorage is easy but any XSS reads it; an HttpOnly cookie hides from JS (dodges XSS) but auto-sends, opening CSRF — so add SameSite and CSRF tokens. Common lean: HttpOnly; Secure; SameSite cookies.
OAuth 2.0 & OpenID Connect
“Sign in with Google” is OAuth. In simple language, OAuth 2.0 lets one app access some of our stuff on another service without ever seeing our password — it’s about delegated access, not login. The problem: a photo-printing site wants our Google Photos. The bad old way hands them our Google password — now they can read our email, delete our account, everything, and we can’t take it back without a password change. OAuth instead gives the printing site a scoped, revocable token (“read photos only”): no password shared, limited access, revocable anytime from Google settings. It’s a hotel key card — not the master key, just a card for one room until checkout. Every flow has the same four roles: Resource Owner (us), Client (the app wanting access), Authorization Server (issues tokens after we approve), and Resource Server (holds the data, honors the token).
The flow to know cold is the Authorization Code flow (+ PKCE): the client redirects us to the auth server (with a PKCE challenge); we log in and approve the scopes; the auth server redirects back with a short-lived authorization code; the client swaps that code plus the PKCE verifier for an access token over a back-channel server-to-server call; then it calls the resource server with the token. Why the two-step (code, then token)? The code returns through the browser’s URL — a leaky place (logs, history, referer) — but the code alone is useless; the real token is fetched out of the browser’s reach. PKCE (Proof Key for Code Exchange, “pixy”) plugs the hole for apps that can’t keep a secret (mobile, SPAs): the client sends the hash of a random code_verifier up front and reveals the original at exchange, so a stolen code can’t be redeemed. It’s now recommended for every client.
The exchange usually returns two tokens: a short-lived access token (sent on every resource call, expires fast if leaked) and a long-lived refresh token (kept private, only used to mint fresh access tokens) — a blast-radius trade. Finally the mix-up: plain OAuth is authorization, and never actually tells the client who you are. OpenID Connect (OIDC) is a thin layer on top that adds an ID token — a JWT with verified identity claims (sub, email, name). One line: OAuth gives an access token (what you can do), OIDC adds an ID token (who you are).
Password Storage & Hashing
Someone signs up with a password; we must check it again next login but must never be able to read it back — that tension is the whole topic, and a favourite interview trap. Rule zero: never store plaintext. If the DB leaks, every account is instantly owned, and because people reuse passwords we’ve handed the attacker their email and bank too. So instead of the password we store a one-way fingerprint of it. Nail the distinction: encryption is reversible (scramble with a key, unscramble with the same key — right for data we need back, like a card number we charge again), while hashing is one-way (fixed-size digest, no “un-hash” — we can only hash a guess and compare). Passwords want hashing; a stolen encryption key would reverse everything.
Why are fast cryptographic hashes (MD5, SHA-256) wrong here? Because they’re built to be fast, which is exactly what we don’t want. An attacker with a leaked hash DB runs every dictionary word, common password, and past-breach password through the hash and compares — a modern GPU does billions of SHA-256/sec. They’re also deterministic and unsalted, so md5("password123") is always the same, letting attackers precompute giant rainbow tables. A salt — a random per-user value mixed in before hashing, stored right next to the hash (not secret) — kills rainbow tables: same password now yields different hashes per user, and precomputed tables are useless because the attacker didn’t know our salt ahead of time. Its job is to be unique and unpredictable per user, forcing per-account cracking. A pepper is the salt’s secret cousin: one shared secret kept outside the DB (env var / secret manager), so if only the DB leaks the attacker’s still missing an ingredient — nice defence-in-depth, but optional.
The real fix for “GPUs guess too fast” is a deliberately slow, adaptive hash — bcrypt (battle-tested, cost factor, each +1 doubles work), scrypt (also memory-hard, resists GPU/ASIC), argon2 (modern winner, OWASP’s top pick, tunable on time/memory/parallelism). They bundle salting in and expose a tunable work factor: tune so one hash costs ~250ms — trivial at login, but turns “billions/sec” into “a handful/sec” for the attacker, and we bump it as hardware improves.
const hash = await bcrypt.hash(plainPassword, 12); // salt auto-generated + baked in
const ok = await bcrypt.compare(loginAttempt, hash); // constant-time, pulls salt out
Top Web Vulnerabilities (OWASP)
Interviewers love “name a few web vulnerabilities and how you’d fix them”, and the good news is most share one root cause: we trusted input we shouldn’t have. SQL Injection is when user input sneaks out of the “data” slot and becomes part of the SQL command — it happens whenever we glue a query together with string concatenation, so the DB can’t tell where our query ends and the attacker’s text begins. A payload like ' OR '1'='1 closes our quote and adds an always-true condition (returns everyone); '; DROP TABLE users; -- deletes data. The fix is parameterized queries / prepared statements: send the query and the values on separate channels so the driver treats input as pure data that can never become SQL. ORMs do this for us; the moment we drop to raw SQL, parameterize — never concat, never “just escape it myself”.
XSS (Cross-Site Scripting) is when an attacker gets their JavaScript to run in our user’s browser — same root cause, different target: we dropped untrusted input into HTML without escaping, so the browser ran it as code. Stored XSS is saved server-side (a comment, a bio) and served to everyone — persistent, most dangerous; reflected XSS rides in the request (a URL search query) and is echoed back — needs a crafted-link click. Fix with output escaping (turn <script> into <script>; frameworks like React auto-escape unless we bypass with dangerouslySetInnerHTML/innerHTML) plus a Content-Security-Policy header (script-src 'self' blocks inline and third-party scripts even if a payload slips through). CSRF (Cross-Site Request Forgery) tricks a logged-in user’s browser into firing an unintended request (a hidden form POSTing to ourbank.com/transfer) — it works because cookies auto-send on every request to our domain. Fix with CSRF tokens (server plants an unpredictable token the attacker’s site can’t read, thanks to same-origin) and the SameSite cookie attribute (Lax/Strict, the modern first line). Note token-in-header JWT auth isn’t auto-sent so it sidesteps CSRF — but is then exposed to XSS. SSRF (Server-Side Request Forgery) is when user input controls a URL our server then fetches (an “import from URL” feature), letting attackers reach internal targets like the cloud metadata endpoint 169.254.169.254 or localhost admin services — fix by allowlisting hosts/schemes, blocking private IP ranges, and not blindly following redirects.
Name-drop the OWASP Top 10 (the industry’s periodic list of critical web risks): recent versions put Broken Access Control at #1 and Injection (folding in SQLi/XSS) near the top.
Rate Limiting, CORS & Secrets Management
Three unrelated but practical topics that all live under “keeping our API safe and well-behaved”. Rate limiting caps how many requests a client can make in a window — to stop abuse (brute-force login, scraping), absorb spikes, and share capacity fairly; it’s the difference between one bad actor taking us down versus just getting throttled. Four algorithms, differing only in how they count and how bursty they allow: fixed window (count per fixed slot, e.g. 100/min — simple, but the counter resets at the boundary so someone fires 100 at 0:59 and 100 at 1:00 = 200 in two seconds); sliding window (the window slides with time, smoothing that boundary burst — more accurate, more bookkeeping); token bucket (a bucket refills tokens at a steady rate, each request spends one — tokens saved while idle allow a burst, empty bucket rejects); leaky bucket (requests drip out at a fixed rate like a queue — constant outflow, no bursts, the one difference from token bucket). When we throttle, respond 429 Too Many Requests with a Retry-After header.
CORS rests on the same-origin policy: by default a browser blocks JS on site-a.com from reading responses from site-b.com, where an origin is scheme + host + port (change any one, different origin) — this stops a malicious page quietly reading our logged-in bank data. CORS (Cross-Origin Resource Sharing) is the controlled relaxation of that rule — the server handing the browser a permission slip via response headers (Access-Control-Allow-Origin/Methods/Headers). Key point people miss: CORS is enforced by the browser, for browser JS — curl, Postman, and server-to-server calls don’t care, so it’s a browser safety feature, not a server-side security control. For “non-simple” requests (a PUT/DELETE, or a custom header like Authorization), the browser first sends an automatic preflight OPTIONS asking “am I allowed?” before the real request; if the headers say no, it blocks and we get the classic console error. The fix for a CORS error is always server-side — configure allowed origins/methods/headers; slapping Allow-Origin: * on everything is a lazy, unsafe reflex.
Secrets (DB passwords, API keys, signing keys) must never leak. Never commit them to git — once pushed they’re in history forever and scraped within minutes; use a .gitignore’d .env locally and commit a blank .env.example. Environment variables are the 12-factor baseline (config from the environment, same image in dev/prod). Secret managers (AWS Secrets Manager, Vault, Doppler) are the step up — encryption at rest, access control, audit logs, and easy rotation (env vars need a redeploy to rotate; a manager rotates and distributes without touching the app). One line: secrets live in the environment or a manager, never in the code or repo, and we should rotate any of them without a scramble.
Databases in Practice
SQL vs NoSQL — Choosing a Database
This is the classic system-design warmup, and the interviewer just wants us to pick the right tool and name the trade-off — not fight a holy war. A SQL (relational) database — Postgres, MySQL, SQL Server — stores data in tables with a fixed schema we define up front, splits data across tables and stitches it back with joins at query time (one users table, one orders table, joined on user_id, no duplication), and gives us ACID transactions so money never vanishes mid-transfer. The big win is data integrity: the schema and foreign keys stop us writing garbage, and it’s genuinely great at pulling data from five tables at once. In simple language, SQL is a spreadsheet with strict columns.
NoSQL is an umbrella for everything that isn’t that table-and-rows model — a big flexible bag where each item can look a little different. It comes in four families worth memorizing:
The real philosophical split is schema-on-write (SQL checks our data against the schema as we write; bad shape rejected — a bouncer at the door) vs schema-on-read (NoSQL writes anything; the reading code figures out the shape — sorted out at the bar). They scale differently too: SQL scales vertically (a bigger box; sharding is painful because cross-node joins and transactions are hard), NoSQL scales horizontally by design, usually paying with eventual consistency (reads may briefly be stale).
Default to SQL for relational data with integrity needs; reach for NoSQL for scale, flexibility, or simple key access — and most real systems mix both (Postgres + Redis + maybe Elasticsearch).
Indexing — How & When
An index is a separate, sorted data structure the database keeps so it can find rows fast without scanning the whole table. In simple language it’s the index at the back of a book — we don’t read all 400 pages to find “goroutines”, we jump to the “G” section and follow the pointer. Without one, the database does a full table scan, reading every row. On a million-row table a WHERE email = ... goes from reading a million rows to maybe three or four — the difference between 800ms and 0.2ms.
But there’s no free lunch. An index is extra data the database must keep in sync, so reads get faster, writes get slower (every INSERT/UPDATE/DELETE now also updates the index), and disk usage grows. Most indexes are B-tree (balanced tree): we start at the root and hop left/right, narrowing the range in O(log n) instead of O(n). Because the tree is sorted, B-trees shine at equality, ranges (>, <, BETWEEN), and ORDER BY — but are useless for a leading wildcard like LIKE '%son', which can’t exploit the ordering.
Two ideas earn their interview keep. A composite index on (a, b, c) obeys the leftmost-prefix rule: it helps queries filtering on a, or a + b, or a + b + c — but not b alone or c alone. Like a phone book sorted by last then first name: great for “Sharma, Asha”, useless for everyone named “Asha”, so put the equality column first. A covering index contains every column the query needs, so the database answers straight from the index without touching the table (an index-only scan) — the fastest thing going.
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
SELECT status FROM orders WHERE user_id = 42; -- fully covered, index-only
Don’t index low-cardinality columns (a boolean barely narrows anything), tiny tables (a scan is already instant), or columns we never filter/join/sort on. Always confirm with EXPLAIN: we want Index Scan, not Seq Scan.
Transactions & ACID
A transaction is a group of database operations that succeed or fail together as one unit — either all of them happen or none do. In simple language it’s an “all or nothing” wrapper around a bunch of statements, with no half-done state. The classic example is transferring ₹1000 from Asha to Rohan: that’s two writes — subtract from Asha, add to Rohan — and if the power dies between them, Asha lost money that never reached Rohan. A transaction guarantees that can never happen.
The four promises spell ACID, the interview bread and butter. Atomicity — all or nothing; if the second write fails, the first is undone. Consistency — the DB moves from one valid state to another with all constraints intact; the total money across both accounts is unchanged, and “balance can’t go negative” is never violated by a committed transaction. Isolation — concurrent transactions don’t step on each other; another transfer can’t see our half-written state (and how much isolation is itself a dial — a whole topic). Durability — once we get “committed”, it’s on durable storage and survives a crash one millisecond later.
Mechanically we open with BEGIN, run our statements, then either COMMIT (make it permanent, all together) or ROLLBACK (throw it all away). Until we commit, nothing is visible to the outside world.
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 'asha';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'rohan';
COMMIT; -- both land together; on any failure, ROLLBACK instead
In app code this is a try/catch: commit on success, rollback in the catch, rethrow. The gotcha worth remembering is auto-commit — by default each lone statement is its own tiny transaction that commits immediately. Fine for single writes, but the moment two writes must move together we have to wrap them in an explicit BEGIN ... COMMIT. Forgetting is a real production bug, not just an interview one.
Isolation Levels & Concurrency Anomalies
An isolation level is a dial for how much one running transaction can see of another’s in-flight work — turn it up and transactions feel more alone but run slower; turn it down and they’re faster but trip over each other. The “I” in ACID isn’t binary, it’s a spectrum. To understand the levels we first need the bad things they prevent, all of which come down to reading data that’s changing under our feet. Dirty read — we read another transaction’s uncommitted change that might roll back (the worst one). Non-repeatable read — we read a row, someone commits an UPDATE to it, we read the same row again and get a different value. Phantom read — we run a range query, someone commits a new row that matches, and re-running returns an extra row. (Like re-reading a paragraph someone’s editing: a pencilled note erased, a word swapped, a whole new sentence appeared.)
The SQL standard defines four levels; each blocks more anomalies at a bit more cost:
Read Committed is the default in Postgres/Oracle; Repeatable Read the default in MySQL InnoDB; Serializable makes transactions behave as if run one-at-a-time. Higher isolation means more locking/version-checking and less concurrency, so the rule is use the lowest level that’s still correct — most apps live at Read Committed, Serializable only for money movements with tricky invariants. Modern engines get this cheaply via MVCC (Multi-Version Concurrency Control): instead of locking, the DB keeps multiple row versions and each transaction reads a consistent snapshot frozen at its start, so readers never block writers and vice versa.
The N+1 Problem & Query Optimization
The N+1 problem is when we run 1 query to fetch a list, then N more — one per item — to fetch related data. A list of 100 posts becomes 101 round-trips: instead of asking the database once, we ask in a loop, hammering it once per row. It’s the single most common performance bug in ORM-heavy backends, and interviewers love it because the fix proves we understand what the ORM is doing underneath. It sneaks in because a line like post.author looks like a plain property access but is secretly a database call, firing fresh inside every loop iteration. On a real page that’s the difference between 20ms and 2 seconds.
The fix is always the same idea: stop looping, start batching — ask for everything in one or two queries. (1) JOIN — let the database stitch posts and authors together in a single round-trip. (2) Eager loading — the ORM way to say “fetch the relation up front, not lazily”, exposed as include, with, or preload; under the hood it’s one JOIN or one follow-up WHERE id IN (...), not a loop. (3) Batching / DataLoader — the GraphQL go-to; a DataLoader collects all the individual .author requests fired in one tick, dedupes them, and fires a single WHERE id IN (1,2,3,...), turning N calls into one while our code stays simple.
SELECT posts.*, users.name AS author_name
FROM posts JOIN users ON users.id = posts.user_id
LIMIT 100; -- one round-trip instead of 101
Beyond the headline fix, a few habits keep queries fast: select only the columns we need (SELECT id, name beats SELECT *, which also breaks covering indexes and drags along blobs/JSON), index the columns we filter and join on (an unindexed JOIN is a full scan hiding in plain sight), paginate with LIMIT/OFFSET or keyset (never fetch 100k rows to show 20), and run EXPLAIN as the truth serum. When a page feels slow, check the query count first — a repeated “1 + N” is the smoking gun.
Connection Pooling & Migrations
Two everyday backend topics that only share “databases at scale”. First, why connections are expensive: opening one means a TCP handshake, then TLS, then authentication, then the database forks or assigns a backend process and allocates memory — like hiring and onboarding a new employee. If every web request opened its own connection, ran one query, and closed it, we’d burn most of our time on setup and teardown; worse, databases cap connections (Postgres defaults to ~100), so a spike could open thousands and knock it over. A connection pool is a fixed set of already-open connections we keep warm and hand out on demand — a request borrows one, runs its query, and returns it to the pool instead of closing it, like a fleet of taxis idling at a stand. Sizing gotcha: bigger is not better. Every app instance has its own pool, so 10 pods at max: 20 aim 200 connections at the database; a pool larger than the DB can handle just moves the queue downstream where contention is worse. Start small (often cores * 2 per instance), tune with metrics, and always set a connection timeout so requests fail fast instead of hanging when the pool drains.
A migration is a versioned script that changes the schema — add a table, column, or index — checked into git so the schema evolves in lockstep with the code (“git for our database structure”). They’re versioned and ordered (a tracking table records which ran, so re-running is safe), forward-only in production (fix mistakes with a new migration, don’t un-apply on a live system), and have up/down steps (down is mostly a dev safety net). Tools: Flyway, Liquibase, knex/Prisma Migrate, Alembic, Rails.
-- 0007_add_last_login.up.sql
ALTER TABLE users ADD COLUMN last_login timestamptz;
For zero downtime use the expand-contract (parallel-change) pattern when renaming name to full_name: (1) expand — add the new column, old code ignores it; (2) migrate — deploy code that dual-writes and backfill; (3) switch — deploy code that reads the new column; (4) contract — drop the old column in a later migration. Additive changes first, destructive changes last, and never in the same deploy.
Caching
Why & Where We Cache
A cache is a fast copy of expensive-to-get data, kept close to whoever needs it — that’s the whole idea. In simple language, instead of asking the slow database the same question over and over, we save the answer somewhere fast and hand out that copy. We bother for three reasons, and every interview answer boils down to them: latency (reading from memory is nanoseconds; hitting a DB across the network is milliseconds), load (every request the cache answers is one the database never sees, shielding the expensive backend), and cost (fewer queries, fewer compute cycles, less bandwidth — serving a cached byte is dirt cheap). Think of a snack in your desk drawer versus walking to the shop each time you’re hungry.
Caching isn’t one thing in one place — it happens at every hop between the user and our data, and a request stops travelling the moment some layer can answer it. From the top down: the browser cache (disk/memory on the device — the fastest possible, the request never even leaves), then the CDN / edge cache (a server near the user, for static assets and cacheable pages), then a reverse proxy (nginx/Varnish caching full HTTP responses), then the application / in-memory cache (Redis, Memcached, or a local map for query results and sessions), and finally the database — the slow source of truth, which caches too via its buffer pool. The higher up we answer, the faster and cheaper it is.
Two words come up constantly: a cache hit means the data’s there — return it straight away; a cache miss means it isn’t, so we fetch from the slow source, usually stash a copy on the way back, then return it. The number we track is the hit ratio (hits ÷ total lookups): a 95% ratio means only 1 in 20 requests bothers the DB, while a 5% ratio is pure overhead. The one price we pay is staleness — a cache holds a copy, and copies drift out of date the moment the real data changes. Every caching decision is really the single question: how stale can we afford to be? Short lifetimes stay fresh but miss more; long lifetimes hit more but risk serving lies.
Takeaway: cache = fast copy at every layer; we win latency, load, and cost, and pay in staleness.
Caching Strategies
A caching strategy is just the rule for who talks to the cache and when. Reads are never the hard part — keeping the cache and the database agreed on what’s true is. There are five patterns interviewers actually ask about, split into read patterns and write patterns.
For reads: cache-aside (lazy loading) is the most common by far — the application manages the cache directly and the cache doesn’t even know the database exists. On a read we check the cache; hit returns it, miss loads from the DB, stashes a copy, then returns it. We only cache what’s actually been asked for (hence “lazy”). The downsides: the first read of anything is always a miss (a “cold cache”), and our code must remember to keep the cache in sync on writes. Read-through is identical behaviour but the cache does the DB fetch on a miss, not our app — we only ever talk to the cache and it fills itself. Simpler app code, but we need a cache that supports a loader.
For writes: write-through sends every write to the cache and the database together, synchronously, before returning “done” — the cache can never be staler than the DB, but we pay two writes on the critical path (safe, slower). Write-behind (write-back) cheats on timing: the write hits the cache and returns immediately, then flushes to the DB later, batched and async — writes feel instant and the DB sees fewer, bigger writes, but if the cache dies before flushing those writes are gone (fast, risky). Write-around is the odd one: on a write we skip the cache entirely and go straight to the DB; the cache only fills later on a read miss. Why? So we don’t pollute the cache with write-heavy, rarely-read data (logs, bulk imports) that would just evict genuinely-hot keys.
async function getUser(id) {
const c = await cache.get(`user:${id}`);
if (c) return JSON.parse(c); // HIT
const user = await db.users.findById(id); // MISS
await cache.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}
Takeaway: most systems run cache-aside for reads plus write-through or write-around for writes.
Cache Invalidation & Eviction
“There are only two hard things in computer science — cache invalidation and naming things.” It’s funny because it’s true. The trick is separating two jobs people constantly mix up: invalidation removes data because it’s wrong now (the source changed), while eviction removes data because we’re out of room (memory is full). Different problems, different tools.
Invalidation has two levers. The simplest is a TTL / expiry: we stamp every entry with a time-to-live and the cache auto-deletes it when the clock runs out — no cleanup code, no coordination. TTL is a bet on staleness (“being up to 60s out of date is fine here”); short TTL is fresher but misses more, long TTL hits more but stales harder. When we can’t tolerate serving stale data until expiry, we do active invalidation on write — kill the entry the moment the underlying data changes, usually right after the DB write. Crucial detail: delete, don’t overwrite. Deleting lets the next read repopulate from the fresh DB (cache-aside style), which sidesteps races where two writers overwrite each other’s cached value in the wrong order.
Eviction kicks in when memory fills, removing still-valid entries — the cache picks a victim using an eviction policy. LRU (Least Recently Used) evicts whatever was touched longest ago — the sensible default, leaning on temporal locality (recently used = probably needed again). LFU (Least Frequently Used) evicts the fewest-accessed key — better when some keys are always hot and shouldn’t die just for going quiet a moment. FIFO evicts the oldest inserted key (simple, rarely optimal), and Random just picks one (zero bookkeeping, surprisingly okay under uniform access). In Redis this is the maxmemory-policy setting (allkeys-lru, allkeys-lfu, and friends).
Finally, the failure that takes down real systems: the cache stampede (thundering herd). A super-popular key expires, and in that instant every request that wanted it misses at once and piles onto the DB together to recompute it. The DB, sized for a trickle, gets a flood and falls over. Two fixes to name: locking (single-flight) — the first miss grabs a lock and recomputes while everyone else waits for that one result, so only one DB trip; and jittered TTL — set 60s ± a few random seconds so keys don’t all expire in sync. A bonus trick is early/probabilistic refresh — recompute a hot key just before it expires, in the background, so it never goes cold.
Takeaway: invalidation removes wrong data (TTL or del-on-write), eviction removes data when full (LRU default), and watch the stampede.
CDNs & Redis in Practice
Caching is one idea in theory, but in practice we reach for two specific tools over and over: a CDN for caching content close to users, and Redis for caching data close to our app.
A CDN (Content Delivery Network) is a fleet of servers spread across the globe that cache copies of our content near the people requesting it. The win is distance — bytes travel at a fixed speed, so instead of every user in Tokyo fetching an image from our origin in Virginia (~9,000 km, every time), the CDN keeps a copy on a machine in Tokyo and serves it from there (~10 km); the origin only gets touched on a miss. Those nearby servers are edge locations (or PoPs, points of presence). A CDN mostly caches static assets — images, JS, CSS, video, fonts — anything identical for every user, and we control it with Cache-Control headers, the shared language between origin, CDN, and browser. Cache-Control: public, max-age=31536000, immutable means any cache may store it, it’s fresh for a year, and it’ll never change (don’t even revalidate) — that’s how we cache hashed files like app.9f2c.js forever, since a new build just gets a new hash and therefore a new URL.
Redis is an in-memory key-value store: hand it a key, get back a value, all living in RAM — which is why it answers in well under a millisecond. It’s the default application cache and session store. It’s fast because everything’s in memory (no disk seek on reads), it’s single-threaded for commands (no lock contention), and it speaks a tiny efficient protocol; the catch is RAM is smaller and pricier than disk, so we cache the hot subset, not everything. Beyond strings it ships real data types — hashes (objects), lists (queues), sets and sorted sets (leaderboards, rate limiters) — so a session or a “top 10 today” is a native structure. On persistence it’s memory-first but can save to disk: RDB takes periodic point-in-time snapshots, AOF logs every write for finer recovery. A pure cache often needs neither; a session store usually wants it.
SET user:42 "Manish" EX 300 # store, expire in 300s
GET user:42 # -> "Manish" (HIT)
DEL user:42 # invalidate on write
Takeaway: CDN for static content at the edge, Redis for dynamic data at the app — big systems run both.
Scalability & Architecture
Vertical vs Horizontal Scaling
Our app is slow because too many people are using it, and we have exactly two knobs. In simple language — vertical scaling (scale up) means we buy a bigger machine: more CPU, more RAM, faster disk, same app running on beefier hardware. Horizontal scaling (scale out) means we buy more machines and spread the load across all of them behind a load balancer. Everything else is trade-offs. Scaling up is dead simple — no code changes, one server, one database, one thing to reason about — which is why it’s the easiest early win. But it hurts eventually: there’s a hard ceiling (a box can only get so big), the top-tier machines cost badly (way more than double for double the power), and one box is a single point of failure — if it dies, everything dies, no redundancy. Think upgrading a hatchback to a truck; great until we need more than one truck’s worth of stuff.
Scaling out has near-infinite headroom (need more? add a box — this is how Google and Netflix run), gives redundancy for free (one dies, the others keep serving), and uses cheaper commodity hardware. The catch is our app now has to cooperate across machines. It must be stateless — no session data or uploaded files pinned to one specific box, because the next request may land elsewhere; state moves to shared storage (Redis, a database, object storage). We also need a load balancer out front, and scaling the data tier means replication and sharding, which is the genuinely hard part.
The honest answer most systems use is do both: scale up first because it’s free engineering-wise, then scale out once we hit the wall or need availability. Reach for vertical when we’re early, traffic is modest, or the workload is one hard-to-split thing. Reach for horizontal when we need high availability, traffic is spiky, or we’ve outgrown the biggest machine money can buy. Interviewers love that horizontal scaling is what enables elasticity — auto-scaling groups add boxes during a spike and drop them at 3am, something a single vertical box can never do.
Takeaway: up is simplest but capped and a SPOF; out is unlimited and redundant but forces statelessness and pushes complexity into the data layer.
Load Balancing
Once we scale out to many servers, something has to decide which server each request goes to — that’s the load balancer. In simple language it’s a traffic cop sitting in front of our pool (the “upstream”), spreading incoming requests so no single box gets hammered. Clients only ever talk to the load balancer’s address; they’ve no idea there are five servers behind it or which one served them. That indirection buys three things at once: distribution (no server melts while others idle), availability (a dead server gets routed around), and flexibility (add or remove servers without clients noticing — great for deploys and auto-scaling).
Load balancers work at one of two layers, and the difference is how much of the request they read. L4 (transport layer) balances on TCP/UDP info only — source/destination IP and port — without reading the request body, so it’s blazing fast and protocol-agnostic (a mail sorter routing by envelope without opening the letter). L7 (application layer) reads the actual HTTP request — URL path, headers, cookies — so it can route /api/* to one pool and /images/* to another, terminate TLS, and do content-aware tricks. The one-liner: L4 routes by address, L7 routes by content. Most modern web setups use L7 (NGINX, HAProxy, AWS ALB) because path/header routing is so useful.
Which server gets a given request? Standard algorithms: round robin (rotating order 1,2,3,1,2,3 — assumes equal servers), least connections (send to whoever has the fewest active connections — better for uneven request times), IP hash (hash the client IP so they always hit the same server — free stickiness), and weighted (beefier servers get a bigger share). The LB also runs health checks — usually a periodic GET /health expecting 200 OK; fail a few in a row and the box is marked unhealthy and pulled from rotation until it recovers. That’s what makes horizontal scaling actually reliable — a dead box quietly drops out instead of serving errors.
Finally, sticky sessions (session affinity) pin a user to one backend for their whole visit, usually via cookie or IP hash — needed when a server holds per-user state in memory. But it’s a smell: it fights even distribution and loses sessions when that box dies. The cleaner fix is stateless servers with sessions in Redis, so any server can serve any request and we don’t need stickiness at all.
Replication & Read Replicas
Scaling the app tier is easy — add more servers. Scaling the database is the hard part, and replication is usually the first tool we reach for. In simple language, replication means keeping live copies of our database on other servers so we read from many machines instead of pounding one. The classic setup is one primary, many replicas (a.k.a. leader/follower, master/slave). The primary is the single source of truth — every INSERT/UPDATE/DELETE goes there. Each replica is a read-only copy; the primary streams its changes and replicas apply them to stay in sync. Our app sends writes to the primary and spreads reads across the replicas. This works beautifully because most apps are read-heavy — often 90%+ reads (timelines, product pages, search) — so offloading reads takes huge pressure off the primary, and we add replicas as read traffic grows.
The big question is: when the primary confirms a write, has it reached the replicas yet? Asynchronous replication commits and replies “done” immediately, then streams to replicas afterward — fast writes, but replicas trail slightly, and if the primary dies right after committing a few just-written rows may be lost. Synchronous waits for at least one replica to confirm before replying “done” — no data loss on failover, but every write eats a network round-trip. Most systems run async by default and use sync only where losing a write is unacceptable (payments). Async is mailing a copy and moving on; sync is waiting for the recipient to sign for it.
Because async replicas trail, there’s replication lag (usually milliseconds, sometimes seconds under load), which causes the nasty read-your-writes bug: a user saves their profile (write → primary), the page reloads reading a replica that hasn’t caught up, and they see their old data and think the save failed. Fixes: read from the primary for a short window after a user’s own write, pin that user to the primary temporarily, or wait for the replica to reach the write’s position. The interview point is just naming it.
Only the primary takes writes, so if it dies, writes stop until failover — promoting a replica to new primary. Manual is safe but slow; automatic (Patroni, RDS Multi-AZ) is fast but must avoid split-brain (two nodes both accepting writes). Remember: replication gives read scaling and high availability, not write scaling — the single primary is still the write bottleneck. That’s what sharding is for.
Sharding & Partitioning
Read replicas scale reads, but the single primary still takes every write. Once writes or data size outgrow one machine, we split the data itself. In simple language — partitioning chops a big table into smaller pieces, and sharding puts those pieces on different servers. Sharding is partitioning that crosses machine boundaries: partitioning stays inside one box (mostly for manageability and letting the engine skip irrelevant chunks), sharding spreads chunks across servers so each shard holds only a slice of data and handles only a slice of traffic — this is the one that actually scales writes and storage horizontally. Two directions to cut: horizontal partitioning splits by rows (users A–M here, N–Z there — same columns, different rows; this is what “sharding” almost always means), and vertical partitioning splits by columns (hot id/name in one table, heavy bio/avatar_blob in another — same rows, different columns).
Everything hinges on the shard key — the column deciding which shard a row lives on (e.g. user_id). It’s the most important and most permanent choice in the whole design. A good one has high cardinality (lots of distinct values) and even access (no single wildly-popular value). Three strategies map key → shard: range-based (users 1–1M on A, 1M–2M on B — simple, keeps range queries on one shard, but hotspots easily since new users pile onto the newest shard), hash-based (hash(user_id) % N — spreads evenly and kills hotspots, but range queries now hit every shard and resharding reshuffles almost everything; consistent hashing softens this), and directory-based (a lookup table mapping key → shard — maximum flexibility, but the directory becomes a component we must keep fast and highly available or it’s a new bottleneck).
Two things make sharding genuinely hard. Hotspots — one shard gets disproportionate traffic (a celebrity user, a viral product, all new writes on the latest range shard); the fix is a better key or splitting the hot shard, neither fun in production. Rebalancing — adding a shard usually means moving live data, and plain hash % N remaps nearly every row on a bump of N, which is why teams reach for consistent hashing or pre-split into many virtual shards. And then the tax: cross-shard queries. JOINs across shards basically don’t work (rows on different servers — we denormalize or join in the app); aggregations like COUNT/SUM/“top 10” must fan out to every shard and merge (scatter-gather, slow); and transactions spanning shards need two-phase commit or sagas.
-- cheap: scoped to the shard key, hits exactly one shard
SELECT * FROM orders WHERE user_id = 42;
-- expensive: no shard key, must hit EVERY shard and merge
SELECT COUNT(*) FROM orders WHERE status = 'pending';
Takeaway: shard as late as we possibly can — the shard key is forever and cross-shard queries are the permanent price.
CAP Theorem & Consistency Models
Once our data lives on multiple machines (replicas, shards), the network between them can break — and then we make a hard choice. The CAP theorem describes it: when the network splits, a distributed system can stay consistent or stay available, but not both. The three letters — C (Consistency): every read sees the most recent write, all nodes agree (this is linearizability, stronger than ACID’s “C”). A (Availability): every request gets a non-error response, even if not the freshest. P (Partition tolerance): the system keeps working even when the network drops or delays messages.
The famous “pick 2 of 3” is a bit of a lie. In any real distributed system network partitions will happen — cables cut, nodes slow, packets drop — so P is not optional. That means the real choice is only between C and A, and only during a partition. When the network is healthy we get all three; the moment it splits we pick: refuse requests to avoid serving stale data → we kept C, gave up A → CP; or keep answering with possibly-stale data → kept A, gave up C → AP. Concretely: the link between two nodes dies, a user writes balance = $100 to node 1 then reads node 2 (which never got it). A CP system makes node 2 refuse the read rather than return a stale $50 — correct but temporarily locked out (banking, inventory, locks, config, etcd, ZooKeeper). An AP system answers with the old $50 and reconciles once the link heals — up but briefly wrong (social feeds, catalogs, carts, DNS, Cassandra, DynamoDB).
CAP’s “C” is all-or-nothing, but real systems live on a spectrum. Strong consistency — after a write completes, every subsequent read anywhere sees it; simple to reason about but needs coordination, so slower and less available (the CP flavor). Eventual consistency — replicas may disagree for a moment but, with no new writes, all converge to the same value eventually; fast and available but reads can be stale (the AP flavor). Useful middle grounds: read-your-writes (we always see our own latest changes) and monotonic reads (we never see time go backwards) make eventual consistency feel far less broken.
Finally PACELC, the footnote CAP forgets: if Partition → choose A or C (the CAP part); Else (normal operation) → choose Latency or Consistency. Even with no partition, keeping replicas perfectly consistent costs latency, so Cassandra is PA/EL and a strongly-consistent store is PC/EC — capturing the everyday trade-off CAP ignores.
Monolith vs Microservices
This is the architecture question in every senior interview. In simple language — a monolith is our whole app as one deployable unit, and microservices split that same app into many small services talking over the network. Neither is “better”; they trade one set of problems for another. A monolith is a single codebase building into a single artifact — all features (auth, billing, notifications) live together, call each other as plain in-process function calls, and usually share one database. Early on it’s great: fast to build (one repo, one deploy, one place to debug), simple transactions (one database means real ACID across features), and easy to reason about (call a function, get a result — no serialization, partial failures, or service discovery). Where it hurts as it grows: scaling is all-or-nothing (if only image-processing is hot, we still scale the entire app), one tech stack (stuck on one language), and scary deploys (a tiny change redeploys everything, and one bad module can crash the whole process).
Microservices slice the app into small, independently deployable services, each owning one business capability and ideally its own database, communicating over REST, gRPC, or async messages. Teams reach for it for independent scaling (scale just checkout during a sale), independent deploys (ship without a giant coordinated release), and tech freedom and fault isolation (best stack per job, one crash doesn’t necessarily kill the rest). The price is steep: distributed-system pain (the network is now inside our app — calls fail halfway, latency stacks, we need retries, timeouts, circuit breakers everywhere), no easy transactions (data across service DBs means sagas and eventual consistency), and operational overhead (service discovery, distributed tracing, dozens of pipelines, versioned APIs — a fleet, not an app).
The most experienced answer is start with a monolith — Martin Fowler’s MonolithFirst. Microservices solve problems of scale and team size we usually don’t have on day one; splitting too early pays the full distributed tax before we even know where the right service boundaries are, and early on those boundaries shift constantly. The winning move: build a well-structured monolith, watch where it actually strains, and carve out services along proven seams. Netflix and Amazon moved to microservices only after their monoliths strained under real load.
There’s a middle ground that’s quietly become the sensible default — the modular monolith. Still one deployable (simple ops, real transactions), but internally split into strict modules with clear boundaries, each owning its own data and talking to others only through defined interfaces (not reaching into each other’s tables). It’s microservices’ clean boundaries without the network between them; if a module ever truly needs to split out, the seam is already there. For most teams this is the sweet spot for a long, long time.
Async & Messaging
Sync vs Async Communication
When two services talk, there are only two options: the caller waits for an answer, or it doesn’t. That’s the whole story. In simple language, synchronous is a phone call — we stay on the line until the other person replies — and asynchronous is a text message — we send it and get on with our life. Synchronous communication is request-response: Service A calls B and blocks until B replies. A plain HTTP call is the classic case — the thread just sits there waiting. The upside is it’s dead simple to reason about: we get an immediate answer, we know right away if it worked, and the code reads top-to-bottom like a normal function call. The catch is coupling — A and B must be up at the same time, if B is slow A is slow, and if B is down A’s request fails. We’ve chained their fates together.
Asynchronous communication means the caller drops a message and moves on immediately, usually through a queue or event in the middle — A leaves a message, B picks it up whenever it’s ready. Now A and B are decoupled in time: B can be down for an hour and the messages just wait, then get processed when B comes back. The trade-off is no immediate answer — if we need the result we have to fetch it some other way (callback, another event, polling), and “did it actually work?” gets harder to answer.
Three things decide the call. Coupling — sync ties A and B tightly; async’s queue is a buffer absorbing outages on either side. Latency — sync answers now but makes us feel B’s slowness; async has higher end-to-end latency but keeps A’s own response snappy. Resilience — async’s big win: a spike or crashed consumer doesn’t take A down, the backlog just drains later. Reach for sync when we need the answer now to continue (reading a page, validating a login, checking stock). Reach for async when the work can happen later and just needs to eventually happen (welcome email, thumbnail, search-index update).
Sync for “I need this to keep going”, async for “please handle this, I’ve got other things to do”.
Message Queues & Producer-Consumer
A message queue is a buffer sitting between whoever produces work and whoever does it — the producer drops a message in one end, the consumer picks it up from the other. In simple language it’s a to-do list that one service writes to and another reads from, and neither has to be online at the same moment. We put a queue in the middle for three reasons: decoupling (the producer doesn’t know or care who reads, so we add/remove/restart consumers freely), load leveling (if 10,000 orders land in a second but our worker handles 100/sec, the queue soaks up the spike and drains steadily — a dam smoothing a flood), and retries (a failed message isn’t lost, it stays or comes back to try again). The key detail of a plain queue: each message goes to exactly one consumer, so adding consumers shares the load — that’s horizontal scaling. (When every consumer should get every message, that’s pub/sub — next topic.)
An acknowledgement (ack) is the consumer telling the broker “I processed this, delete it now.” Until the ack arrives the broker holds the message, so if the consumer crashes mid-processing the broker notices the dropped connection and re-delivers to another consumer — nothing silently lost. A nack requeues or rejects. The golden rule: ack after the work is done, never before, or a crash loses it forever.
channel.consume("emails", async (msg) => {
await sendEmail(JSON.parse(msg.content.toString()));
channel.ack(msg); // done — broker can delete it now
});
Delivery guarantees come in three levels: at-most-once (zero or one delivery — fast, but can lose messages; fine for metrics), at-least-once (one or more — nothing lost but crashes cause duplicates; the common default in RabbitMQ and SQS standard), and exactly-once (the dream, genuinely hard and expensive in a distributed system). In practice most systems pick at-least-once plus idempotent consumers — stamp each message with an ID, remember what we’ve handled, skip repeats — getting the effect of exactly-once without the pain. Messages that just won’t process (“poison messages”) land in a dead-letter queue (DLQ) after N attempts, so they stop blocking the main queue and we inspect or replay them later. RabbitMQ (publish to an exchange that routes to queues) and Amazon SQS (Standard = at-least-once, FIFO = strict order + dedup) are where we see this.
Pub/Sub & Event-Driven Architecture
Publish/subscribe is a messaging pattern where a publisher sends one message and every interested subscriber gets a copy. The publisher has no idea who’s listening — it just announces “this happened” and walks away. In simple language, a queue is a direct message to one worker; pub/sub is a group announcement everyone subscribed hears. That’s the one difference interviewers love: both are async, but the fan-out is opposite — a queue message goes to exactly one consumer, a pub/sub event goes to every subscriber. The middle box in pub/sub is a topic — a named channel like order.placed or user.signed-up. Publishers send to the topic, subscribers register interest in it, and the topic is what lets N publishers and M subscribers connect without knowing each other.
Event-driven architecture (EDA) makes pub/sub the backbone of the whole system. Instead of a service commanding another (“hey inventory, decrement this”), it just emits an event describing what happened (“an order was placed”), and other services react to events they care about. The mental shift: nobody commands anyone — services announce facts and interested parties respond on their own. One order.placed event might trigger a confirmation email, an inventory decrement, and an analytics update — and the order service wrote none of that follow-up logic. Think of a newsroom: a reporter publishes a story and whoever’s subscribed reacts however they want; the reporter doesn’t phone each reader.
The upside is loose coupling (add a new subscriber like a fraud-check service without touching the publisher), independent scalability (email service runs 2 instances, analytics runs 20, same event stream), and extensibility (new features are often just “subscribe to an existing event”). Be honest about the downsides in interviews: it’s harder to trace and debug (one publish fans out to who-knows-how-many reactions, so we lean on correlation IDs and distributed tracing), it brings eventual consistency (right after order.placed, inventory might not be decremented yet — everything settles in a moment but “right now” parts can disagree), and there’s no built-in “did it work?” so error handling moves into each subscriber. The textbook service is Amazon SNS, and the classic SNS → SQS fan-out pattern delivers one event into many durable queues, each drained by its own service.
Kafka & Event Streaming Basics
Kafka is a distributed, append-only log — not a queue that hands out messages and deletes them, but a durable log that keeps appending records to the end, like a never-ending file consumers read through at their own pace. In simple language it’s a giant shared logbook: producers write new lines at the bottom, and each reader keeps their own bookmark for how far they’ve read. This is the mental model everything hangs off. A traditional queue deletes a message once consumed and the broker tracks delivery; Kafka does the opposite — it keeps every record for a retention period and each consumer tracks its own position. That flip is the whole reason Kafka exists: because records stick around and readers own their position, we get replay — a new consumer can read all of history from the start, or a broken consumer can rewind and re-process. A queue can’t do that; once a message is gone, it’s gone.
The core terms fit together neatly. A broker is one Kafka server; a cluster is several sharing load and replicating for durability. A topic is a named stream like payments. A topic is split into partitions, and each partition is its own ordered, append-only log — the unit of both parallelism and ordering. An offset is a record’s sequential position within a partition (0, 1, 2…) — the bookmark. A producer writes records and picks a partition (usually by hashing a key). A consumer group is a set of consumers splitting a topic’s partitions, with Kafka guaranteeing each partition is read by exactly one consumer in the group.
Partitions buy us two things usually in tension: ordering and parallelism. Ordering is guaranteed per partition, not per topic — so to keep related events ordered (all events for order-42), we give them the same key, and Kafka hashes it to one partition. Parallelism comes from having many partitions, each processed by a different consumer; the partition count is the ceiling — more consumers than partitions and the extras sit idle. One partition is a single-file queue; many partitions are extra checkout lanes (fast, but order kept only within a lane). Retention keeps records for a time or size window regardless of consumption, so replay is trivial — reset the group’s offset to earliest and re-read everything, perfect for reprocessing after a bug fix or backfilling a new service.
Reliability & Operations
Observability — Logs, Metrics & Traces
Observability is how well we can understand what’s happening inside a running system just by looking at what it emits from the outside — the difference, at 2am, between “I can see exactly why” and “let me add some logs, redeploy, and pray”. A backend of a dozen services has too many moving parts to hold in our head, so we make the system tell us what it’s doing through three signals, and interviewers love asking us to name all three.
Logs are discrete, timestamped events (“payment failed with code 402”) — the detail layer. The one upgrade that matters is structured logging: emit a JSON object with fields instead of a human sentence, so we can filter by event=payment_failed AND code=402 rather than grepping strings. Metrics are numbers over time — request count, error rate, queue depth. They’re cheap (just numbers + timestamps) so we keep them forever and run dashboards and alerts on them. Two shortcuts for which to track: RED (Rate, Errors, Duration) for anything that serves traffic like an API, and USE (Utilization, Saturation, Errors) for resources that have capacity like CPU or a connection pool. Traces follow one request across every service hop; each trace is made of spans (one unit of work — an HTTP call, a DB query — with a start, duration, and parent), stitched into a waterfall showing where the 800ms went. The glue is a trace/correlation id: the first service generates it, passes it downstream in a header, and every log and span tags itself with it — like a package tracking number showing every warehouse the parcel passed through.
Interviewers like the monitoring vs observability distinction: monitoring watches known problems we predicted in advance (“alert if error rate > 1%”); observability lets us ask new questions about something novel without shipping new code. And a good alert fires on symptoms tied to user pain — high error rate, high latency, SLO burn — not raw causes like “CPU is 90%”. The SRE mantra: page on symptoms, not causes.
Health Checks & Graceful Shutdown
A health check is a tiny endpoint our service exposes so an orchestrator can ask “are you okay?” without guessing — the service answering a doctor’s “how are you feeling?” so the platform knows whether to route traffic or restart. In a container world, Kubernetes or a load balancer is constantly deciding where to send requests and when to reboot a sick instance, and it can only decide well if each instance reports honestly. There are two different questions, hence two probes.
Liveness = “are you alive, or should I kill and restart you?” A failed liveness probe means the process is wedged (deadlocked, stuck) and a restart is the fix. Readiness = “are you ready for traffic right now?” A failed readiness probe means “alive but warming up — hold traffic, don’t restart me”, and the platform silently stops routing to us. We need both because “broken forever, restart me” and “temporarily not ready, hold traffic” demand opposite responses — restarting a service that’s merely warming its cache would be a disaster loop. Keep /healthz (liveness) dumb and cheap, and make /readyz (readiness) actually check dependencies like “can I reach the DB?”, so a slow DB doesn’t get the whole process killed.
Graceful shutdown is the flip side: when we deploy, the platform stops old instances, and if we just yank the plug, in-flight requests get dropped as 502s. The trigger is SIGTERM — the platform sends it and gives a grace period (Kubernetes default 30s) before the un-ignorable SIGKILL. The correct sequence, in order: (1) fail readiness first so no new traffic arrives, (2) stop accepting — close the listener, (3) drain in-flight requests up to a deadline, (4) close resources — DB pool, queues, files, (5) exit(0) cleanly before the grace period ends. Order matters: flip readiness before closing the listener, or requests still in transit get refused. Always add a setTimeout safety net that force-exits if draining hangs, so one stuck request doesn’t leave us to be SIGKILL’d. Do this and rolling deploys drop zero requests.
Timeouts, Retries & Circuit Breakers
When our service calls another, that call can be slow, fail, or hang forever. These three patterns stop one flaky dependency from dragging us down with it — how we call something unreliable without betting our own uptime on it. The danger is a cascading failure: a downstream gets slow → our requests pile up waiting → our connections/threads exhaust → we go down → whatever calls us goes down.
A timeout is a hard limit on how long we’ll wait before giving up — the most important and most-forgotten one. Without it, a hung call holds our resources hostage: one stuck dependency slowly consumes every connection in our pool and now we’re down too, even though our own code is fine. Every network call — HTTP, DB, cache — gets a deadline; “wait forever” is never right. A retry is trying again on the bet the failure was transient, but two rules apply. First, only retry idempotent operations — a GET or a PUT that sets a value is safe; retrying a POST /charge might charge twice, so add an idempotency key if we can’t guarantee it. Second, use exponential backoff + jitter — wait longer each time (1s, 2s, 4s…) plus randomness, so a thousand clients don’t retry in lockstep. The trap interviewers love is the retry storm: a struggling service gets tripled traffic as everyone retries, and falls over completely.
The real fix for a genuinely overloaded downstream is the circuit breaker — a state machine that stops calling a clearly-failing service so we fail fast instead of piling on, exactly like the electrical breaker in our house. Closed: calls flow, we count failures. Open: threshold crossed, breaker trips, every call fails instantly without touching the downstream — giving it room to recover. Half-open: after a cooldown, a few probe requests go through; succeed → close, fail → snap back open. Finally, bulkheads — give each dependency its own connection/thread pool (like sealed compartments in a ship’s hull) so one hung service can’t drain the rest.
Distributed Transactions (Saga & Outbox)
A distributed transaction is one logical unit of work — “place an order” — spanning multiple services, each with its own database. The honest truth interviewers want: we can’t keep them consistent cleanly, so we settle for eventual consistency. In one database, a transaction is ACID — BEGIN, three writes, COMMIT, all-or-nothing. The moment “place an order” touches the Order, Payment, and Inventory DBs, that guarantee is gone: no shared BEGIN/COMMIT across three separate databases, each commits or fails independently.
The textbook fix is 2PC (two-phase commit) — a coordinator asks everyone “can you commit?”, and if all say yes, tells everyone “commit”. We avoid it in microservices because it’s blocking (everyone holds locks awaiting the coordinator), the coordinator is a single point of failure (if it dies mid-commit, participants are stuck holding locks), and it couples services tightly. Instead we reach for the saga: break the big transaction into a sequence of local transactions, one per service, each doing its own little ACID commit then triggering the next. There’s no automatic rollback, so every step defines a compensating action — an explicit undo. If step 4 fails, we run compensations for 3, 2, 1 in reverse; we don’t roll back, we apologize and reverse (charge → refund, create order → cancel order). Two coordination styles: choreography (no central boss — each service listens for events and emits its own; decentralized but the flow is scattered and hard to follow) and orchestration (one orchestrator holds the workflow and directs each step; easier to reason about but a component to build and maintain). Rule of thumb: choreography for simple 2–3 step flows, orchestration once it gets complex.
Sagas hit the dual-write problem: a service must atomically update its DB and publish an event, but those are two systems with no shared transaction — commit the DB, crash before publishing, and the saga stalls. The transactional Outbox fixes it: write the event into an outbox table in the same local transaction as the business data, so both commit atomically. A separate relay (poller or CDC) then reads unpublished rows and ships them to the broker at-least-once — so downstream consumers must be idempotent.
Docker & Containers for Backend
A container is just a normal process on the host wrapped in isolation so it thinks it has its own machine — its own filesystem, network, and process tree — bundled with all the dependencies our app needs. It exists to kill the oldest complaint in software: “works on my machine”. By shipping the app and its exact environment together, “my machine” and “the server” become identical boxes.
The classic interview question is container vs VM. A virtual machine virtualizes hardware: each VM runs a full guest OS with its own kernel on a hypervisor — heavy, gigabytes, tens of seconds to boot. A container virtualizes the OS: all containers share the host’s kernel, isolated by kernel features (namespaces for “what you can see”, cgroups for “how much you can use”) — no guest OS, so tiny (megabytes) and starting in milliseconds. A VM is a whole separate house next door; a container is a locked private room in the same house, sharing the plumbing (kernel) but with its own door. Trade-off in a line: VMs give stronger isolation (separate kernels), containers give far more speed and density. Don’t confuse image vs container either: an image is the read-only blueprint (snapshot of app + deps); a container is a running instance of it with its own writable layer — classes vs objects, one image → many identical containers.
The build detail interviewers probe is Dockerfile layers & caching: each instruction creates a layer, and Docker caches layers, reusing a cached layer on rebuild if it and everything above it are unchanged. So instruction order matters — put rarely-changing things first, often-changing things last.
COPY package*.json ./ # deps first — cached until package.json changes
RUN npm ci
COPY . . # source last — a code edit only rebuilds from here
CMD ["node", "index.js"]
Copying COPY . . before npm ci would make every code edit reinstall every dependency. Finally, a multi-stage build uses a heavy stage to build and a clean slim stage that copies only the finished artifact — smaller image, faster deploys, smaller attack surface.
CI/CD & the 12-Factor App
CI/CD is the automated assembly line taking code from a git push to running in production with minimal manual fiddling — so humans don’t build and deploy by hand and forget a step at 5pm on a Friday. Manual deploys are slow, inconsistent, and error-prone; automating them lands changes fast in small batches, tested identically every time, so a bad deploy is a quick rollback rather than a crisis.
Interviewers want the acronym split cleanly. CI (Continuous Integration) — developers merge to shared main frequently (many times a day) and every merge auto-triggers a build + test run, catching integration problems while the change is small instead of during a giant painful merge later. CD has two flavors people conflate: Continuous Delivery — every change passing CI is auto-prepared and made deployable, but a human clicks release; Continuous Deployment — one step further, passing changes go straight to production automatically, no human gate. The only difference is that last manual button: delivery = “always ready, we choose when”; deployment = “green means live”. A typical pipeline gates build → test → scan → deploy (compile/package → unit+integration tests → lint/vulns/secrets → push to environment); any stage red stops everything and nothing ships. The build produces one immutable artifact (a Docker image) promoted unchanged through staging to prod, so what we tested is exactly what runs — no rebuilding per environment.
The twelve-factor app is a set of cloud-native principles; don’t recite all twelve (a red flag), just the ones shaping backend design. Config in the environment — DB URLs, keys, flags live in env vars, never committed, so one identical build runs everywhere and secrets stay out of git. Stateless processes — keep no important state in local memory/disk between requests (sessions in Redis, not in-process), so we can kill, restart, and scale freely and any instance handles any request — this is what makes horizontal scaling work. Backing services as attached resources — a DB, cache, or queue is reached by URL and swappable by flipping one env var, no code change. Logs as event streams — the app writes to stdout and lets the platform capture and route it. Dev/prod parity — keep dev, staging, and prod as similar as possible (containers help enormously) to avoid “worked in dev, broke in prod”. There’s also disposability — start fast, shut down gracefully on SIGTERM — tying straight back to health checks and graceful shutdown.