Skip to the questions

Backend Developer · Fresher to 3 years

Backend interview questions

The 40 questions that come up most in backend developer interviews, with examples in Node.js and Express. Each one has a short answer you can say out loud in about 30 seconds, and the full explanation, with tested code, for when you need it.

Read the quick answers

5 HTTP · 6 REST · 5 Node.js · 3 Express · 8 Auth & security · 4 Databases · 3 Caching · 3 Architecture · 3 DeploymentUpdated 27 Oct 2026

When is your interview?

01Web and HTTP basics

0/5 got it

Q01Not marked yetFresherAsked oftenConcept

What does an HTTP request contain, and what does the server send back in the response?

Say this first~30 sec to say

A request has a method (GET, POST and so on), a URL (the path plus any query string), headers with metadata such as Host, Authorization, Content-Type and cookies, and an optional body, for example the JSON of a POST. The response has a status code such as 200 or 404, headers such as Content-Type, Set-Cookie and Cache-Control, and a body, usually JSON or HTML. HTTP is stateless: every request carries everything the server needs to handle it.

Could you say it?
Full answer3 min read

What actually goes over the wire

POST /api/orders?source=app HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
Content-Length: 25

{"item":"kettle","qty":1}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/42

{"id":42,"status":"created"}

The same exchange in Node.js

JavaScript
import http from "node:http";

const server = http.createServer((req, res) => {
  let body = "";
  req.on("data", (chunk) => (body += chunk));
  req.on("end", () => {
    console.log(req.method, req.url, req.headers["content-type"], body);
    res.writeHead(201, { "Content-Type": "application/json", Location: "/api/orders/42" });
    res.end(JSON.stringify({ id: 42, status: "created" }));
  });
});

server.listen(3000, async () => {
  const res = await fetch("http://localhost:3000/api/orders?source=app", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ item: "kettle", qty: 1 }),
  });
  console.log(res.status, res.headers.get("location"), await res.json());
  server.close();
});
POST /api/orders?source=app application/json {"item":"kettle","qty":1}
201 /api/orders/42 { id: 42, status: 'created' }

The client–server model: the client (a browser, a mobile app, another service) always starts the conversation; the server only ever responds. Because HTTP is stateless, "who is this?" has to travel with every request, in a cookie or a token (questions 4 and 21).

Follow-ups

  • HTTP/1.1 vs HTTP/2 vs HTTP/3? HTTP/2 sends many requests over one connection at once (multiplexing) with compressed headers. HTTP/3 does the same over QUIC, on UDP, which avoids head-of-line blocking when packets are lost.
  • Content-Type vs Accept? Content-Type says what format the body being sent is in; Accept says what format the client wants back.
Q02Not marked yetFresherAsked very oftenConcept

GET vs POST: how do they differ, and when would you use each?

Say this first~30 sec to say

GET reads data: parameters go in the URL's query string, it must not change anything on the server (it's safe), and it can be cached, bookmarked and retried freely; URLs also end up in logs and history. POST sends data in the body to create something or trigger an action; it isn't safe or idempotent, and isn't cached by default. Use GET for searches and lookups, POST for logins, forms and creating resources.

Could you say it?
Full answer2 min read
GETPOST
PurposeRead dataCreate something, or trigger an action
Where the data goesQuery string: /products?q=kettleRequest body
Changes server stateMust not (safe)Usually does
IdempotentYesNo: two POSTs can create two orders
Cached by browsers and CDNsYes, if headers allowNot by default
Bookmarkable, shareableYesNo
Visible in logs and historyYesThe body isn't
Size limitsPractical URL limits (a few KB)Large bodies are fine
JavaScript
import express from "express";

const app = express();
app.use(express.json());

const products = [{ id: 1, name: "Electric Kettle" }, { id: 2, name: "Steel Bottle" }];

app.get("/products", (req, res) => {                     // read: parameters in the URL
  const q = String(req.query.q ?? "").toLowerCase();
  res.json(products.filter((p) => p.name.toLowerCase().includes(q)));
});

app.post("/products", (req, res) => {                    // create: data in the body
  const product = { id: products.length + 1, name: req.body.name };
  products.push(product);
  res.status(201).json(product);
});

export default app;

GET /products?q=kettle returns the kettle; POST /products with {"name": "Water Purifier"} creates product 3 and returns 201.

POST isn't "more secure". Both travel in plain text over HTTP and both are encrypted over HTTPS. The real difference is that GET parameters land in URLs, which are logged, cached and shown in history, so never put passwords or tokens in a query string.

Follow-ups

  • Can a GET request have a body? The spec gives it no meaning, and many servers, proxies and libraries drop it. Don't rely on it.
  • What do PUT, PATCH and DELETE add? Replacing a resource, partly updating it, and removing it (question 7).
Q03Not marked yetFresherAsked very oftenConcept

What do the 2xx, 3xx, 4xx and 5xx status code classes mean, and when would you return 201, 400, 401, 403, 404 or 500?

Say this first~30 sec to say

2xx means success, 3xx a redirect, 4xx that the client did something wrong, and 5xx that the server failed. Return 201 Created after creating a resource, with a Location header; 400 Bad Request for malformed or invalid input; 401 Unauthorized when the request isn't authenticated; 403 Forbidden when the user is known but not allowed; 404 Not Found when the resource doesn't exist; and 500 for unexpected failures, without leaking internal details.

Could you say it?
Full answer3 min read
CodeNameReturn it when
200OKA successful GET, PUT or PATCH with a body
201CreatedA POST created something; add a Location header
204No ContentSuccess with nothing to send back, e.g. DELETE
301 / 302Moved Permanently / FoundRedirects
304Not ModifiedThe client's cached copy is still current
400Bad RequestMalformed JSON, missing or invalid fields
401UnauthorizedNo credentials, or invalid or expired ones
403ForbiddenAuthenticated, but not allowed to do this
404Not FoundNo such resource (or hiding one the user mustn't know exists)
409ConflictA duplicate, e.g. the email is already registered
422Unprocessable ContentWell-formed but semantically invalid input (some APIs use 400 for this too)
429Too Many RequestsRate limit hit (question 27)
500Internal Server ErrorAn unexpected bug
502 / 503Bad Gateway / Service UnavailableAn upstream service failed, or the server is overloaded or in maintenance
JavaScript
import express from "express";

const app = express();
app.use(express.json());
const orders = new Map([[1, { id: 1, ownerId: 7, total: 1299 }]]);

app.get("/orders/:id", (req, res) => {
  const userId = Number(req.get("X-User-Id"));           // stands in for real authentication
  if (!userId) return res.status(401).json({ error: "Sign in first" });
  const order = orders.get(Number(req.params.id));
  if (!order) return res.status(404).json({ error: "Order not found" });
  if (order.ownerId !== userId) return res.status(403).json({ error: "Not your order" });
  res.json(order);                                        // 200
});

app.post("/orders", (req, res) => {
  if (!Number.isInteger(req.body.total) || req.body.total <= 0) {
    return res.status(400).json({ error: "total must be a positive integer" });
  }
  const order = { id: orders.size + 1, ownerId: 7, total: req.body.total };
  orders.set(order.id, order);
  res.status(201).location(`/orders/${order.id}`).json(order);
});

export default app;

401 vs 403, the favourite follow-up: 401 means "I don't know who you are": sign in and try again. 403 means "I know exactly who you are, and the answer is no": signing in again won't help. The name "Unauthorized" for 401 is a historical misnomer; it really means unauthenticated.

Follow-ups

  • Should an API return 200 with {"error": ...} in the body? No. Clients, proxies, monitoring and retries all rely on the status code; hiding failures behind 200 breaks them.
  • Why might you return 404 instead of 403? To avoid revealing that a private resource exists at all, for example someone else's order ID.
Q04Not marked yetFresherAsked very oftenConcept

Cookies vs sessions: where does each live, and how do they work together to keep a user logged in?

Say this first~30 sec to say

A cookie is a small value the server asks the browser to store, with Set-Cookie; the browser sends it back with every request to that site. A session is data the server keeps about a user, in memory, a database or Redis, under a random ID. Together: after login, the server creates a session and sends only its ID in a cookie marked HttpOnly, Secure and SameSite, then looks it up on each request.

Could you say it?
Full answer3 min read
JavaScript
import crypto from "node:crypto";
import express from "express";

const app = express();
const sessions = new Map();                   // in production: Redis or a database table

function sessionId(req) {
  return req.headers.cookie?.match(/(?:^|;\s*)sid=([^;]+)/)?.[1];
}

app.post("/login", (req, res) => {
  // ...check the email and password first (question 23)
  const sid = crypto.randomBytes(32).toString("hex");   // unguessable
  sessions.set(sid, { userId: 7, name: "Asha" });
  res.cookie("sid", sid, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 24 * 60 * 60 * 1000 });
  res.sendStatus(204);
});

app.get("/me", (req, res) => {
  const session = sessions.get(sessionId(req));
  if (!session) return res.sendStatus(401);
  res.json(session);
});

app.post("/logout", (req, res) => {
  sessions.delete(sessionId(req));            // logout takes effect immediately
  res.clearCookie("sid");
  res.sendStatus(204);
});

export default app;

The login response carries Set-Cookie: sid=…; Max-Age=86400; Path=/; Expires=…; HttpOnly; Secure; SameSite=Lax. After that, GET /me with that cookie returns {"userId":7,"name":"Asha"}, and after logout the same cookie gets 401.

CookieSession
LivesIn the browserOn the server
HoldsA small string, up to about 4 KBAny amount of data
Can the user see or edit it?YesNo
SentAutomatically, with every request to the siteNever: only its ID travels, inside a cookie

The cookie attributes that matter for a session cookie

  • HttpOnly: JavaScript can't read it, so an XSS bug can't steal it.
  • Secure: only sent over HTTPS.
  • SameSite=Lax or Strict: not sent on most cross-site requests, which blocks CSRF attacks.
  • Max-Age or Expires: how long it lives; without one, it's deleted when the browser closes.

Follow-ups

  • What happens with several servers? An in-memory Map only exists on one server, so put sessions in a shared store such as Redis.
  • Session or JWT? Sessions are easy to revoke and keep the token small; JWTs need no lookup and scale without shared state (question 21).
Q05Not marked yetFresherAsked very oftenConcept

What does HTTPS add on top of HTTP?

Say this first~30 sec to say

HTTPS is HTTP sent over TLS, which adds three things. Encryption: anyone on the network, such as the café Wi-Fi or an ISP, sees only scrambled bytes, not passwords or cookies. Authentication: the server proves its identity with a certificate signed by a trusted certificate authority, so you know you've reached the real bank, not an impostor. Integrity: any tampering in transit is detected. The handshake uses public-key cryptography to agree a shared key, then fast symmetric encryption carries the data.

Could you say it?
Full answer2 min read

The TLS 1.3 handshake, simplified

  1. Client hello: the browser lists the cipher suites it supports and sends its half of a key exchange.
  2. Server hello: the server picks a cipher, sends its half of the key exchange and its certificate, signed by a certificate authority the browser trusts.
  3. The browser checks the certificate: the right domain, not expired, a valid chain up to a trusted root.
  4. Both sides derive the same session keys from the exchange, without ever sending them.
  5. From here on, every request and response is encrypted with those keys.

TLS 1.3 does this in one round trip, which is part of why HTTPS adds very little delay today.

Without HTTPSWith HTTPS
Passwords, cookies and tokens readable on the networkEncrypted
Anyone can impersonate the siteThe certificate proves the site's identity
Content can be changed in transit (injected ads, malware)Tampering is detected
Browsers label the site "Not secure"Padlock; required for many browser features

What HTTPS doesn't hide or fix: the domain you're visiting is still visible (through DNS and the TLS server name), as are IP addresses and traffic sizes. And it does nothing about bugs on the server itself, such as SQL injection.

Follow-ups

  • What is HSTS? A response header, Strict-Transport-Security, telling browsers to use only HTTPS for the site from then on, which blocks downgrade attacks.
  • Where do certificates come from? A certificate authority; Let's Encrypt issues them free and automatically. The Frontend kit's question 38 places the TLS step within the full "type a URL" journey.

02REST API design

0/6 got it

Q06Not marked yetFresherAsked very oftenConcept

What is REST, and what makes an API RESTful?

Say this first~30 sec to say

REST is an architectural style for APIs built around resources. Each resource has a URL, such as /users/42, and clients act on it with standard HTTP methods: GET to read, POST to create, PUT or PATCH to update, DELETE to remove, getting back a representation, usually JSON, with a meaningful status code. Requests are stateless, each carrying its own context such as an auth token, so any server can handle any request, and responses say whether they can be cached.

Could you say it?
Full answer2 min read

RESTful, and not

Not RESTfulRESTful
POST /getUser with {"id": 42}GET /users/42
GET /deleteUser?id=42DELETE /users/42
POST /users/42/updateEmailPATCH /users/42 with {"email": "..."}
200 OK with {"error": "not found"}404 Not Found
The server remembers the last page you viewedThe client sends ?page=3 each time

A resource and its operations

Method and pathMeaningSuccess code
GET /ordersList orders (filter with query parameters)200
POST /ordersCreate an order201
GET /orders/42Read one order200
PATCH /orders/42Change some fields200
DELETE /orders/42Delete it204
GET /orders/42/itemsA sub-resource: the order's items200

The constraints behind it, from Roy Fielding's 2000 dissertation: client–server separation, statelessness, cacheability, a uniform interface (resources, methods, representations), and a layered system, so proxies and load balancers can sit in between.

Follow-ups

  • Is REST a protocol or a standard? Neither: it's a style. Most real "REST APIs" follow it loosely, and that's fine if they're consistent.
  • What is HATEOAS? Responses that include links to related actions, so clients can discover the API as they go. It's part of strict REST, but rarely implemented.
  • REST or RPC? RPC exposes actions (createInvoice, sendReminder); REST exposes resources. gRPC is a popular RPC framework for service-to-service calls.
Q07Not marked yetFresherAsked very oftenConcept

PUT vs PATCH: when should an update endpoint use each?

Say this first~30 sec to say

PUT replaces the whole resource with what you send: fields you leave out are reset or removed, and repeating it changes nothing further, so it's idempotent. PATCH changes only the fields you send and leaves the rest alone: right for partial edits, like changing just a phone number. Use PUT when the client sends the complete object, PATCH for a few fields. PATCH isn't guaranteed idempotent; think "add 1 to stock".

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

const app = express();
app.use(express.json());
const users = new Map([[1, { id: 1, name: "Asha", email: "[email protected]", city: "Pune" }]]);

app.put("/users/:id", (req, res) => {
  const id = Number(req.params.id);
  const { name, email, city } = req.body;
  if (!name || !email) return res.status(400).json({ error: "name and email are required" });
  const existed = users.has(id);
  const user = { id, name, email, city: city ?? null };   // the whole resource, replaced
  users.set(id, user);
  res.status(existed ? 200 : 201).json(user);
});

app.patch("/users/:id", (req, res) => {
  const user = users.get(Number(req.params.id));
  if (!user) return res.sendStatus(404);
  for (const field of ["name", "email", "city"]) {
    if (field in req.body) user[field] = req.body[field];  // only the fields that were sent
  }
  res.json(user);
});

export default app;
Request to user 1Result
PATCH {"city": "Bengaluru"}{ id: 1, name: "Asha", email: "[email protected]", city: "Bengaluru" }
PUT {"name": "Asha", "email": "[email protected]"}{ id: 1, name: "Asha", email: "[email protected]", city: null }: city was left out, so it's gone
PUT without an email400: a PUT must send the complete resource
PUTPATCH
SendsThe complete resourceOnly the changes
Missing fieldsCleared or resetLeft as they were
IdempotentYes, by definitionNot guaranteed
Can createYes, at a client-chosen URL, if the API allows itUsually not

Follow-ups

  • Why does idempotency matter? Clients and proxies can safely retry an idempotent request after a timeout without causing double effects (question 8).
  • What formats exist for PATCH bodies? Most APIs take a partial JSON object (JSON Merge Patch). JSON Patch is a list of operations, [{ "op": "replace", "path": "/city", "value": "Pune" }], for precise edits.
Q08Not marked yet1–3 yrsAsked oftenConcept

What makes an API operation idempotent, and how would you make a "create payment" POST safe to retry?

Say this first~30 sec to say

An operation is idempotent if doing it once or many times leaves the server in the same state. GET, PUT and DELETE are; POST isn't, so retrying a "create payment" after a timeout could charge twice. The fix is an idempotency key: the client sends a unique key per payment attempt in a header, the server stores the key with the result, and a repeat of that key gets the saved result instead of a new charge.

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

const app = express();
app.use(express.json());

const results = new Map();     // in production: a database table with a unique key, or Redis
let charges = 0;

async function chargeCard({ amount }) {
  charges += 1;
  await new Promise((resolve) => setTimeout(resolve, 50));   // the slow call to the payment provider
  return { paymentId: `pay_${charges}`, amount, status: "captured" };
}

app.post("/payments", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key header is required" });

  if (!results.has(key)) {
    results.set(key, chargeCard(req.body));    // store the promise, so a duplicate arriving mid-charge waits for it
  }
  res.status(201).json(await results.get(key));
});

export default app;

Two simultaneous requests and a later retry, all with Idempotency-Key: 9f1c…, all receive { paymentId: "pay_1", amount: 499, status: "captured" }, and charges stays at 1. A request with a different key creates pay_2.

Details a production version needs

  • Store keys durably, with a unique constraint, so two servers handling the same key at the same moment can't both charge.
  • Check the request matches: the same key with a different amount should be rejected (422), not answered with the old result.
  • Decide what to store for failures: a definite decline can be stored and replayed; a crash halfway through needs a "processing" state and a way to resume or roll back.
  • Expire old keys, for example after 24 hours.

Payment APIs such as Stripe accept exactly this kind of Idempotency-Key header.

MethodSafe (no side effects)Idempotent
GET, HEADYesYes
PUTNoYes
DELETENoYes: deleting twice still leaves it deleted (the second may return 404)
POSTNoNo
PATCHNoNot guaranteed

Follow-ups

  • Who generates the key? The client, once per user action (say, per click on "Pay"), and reuses it for every retry of that action.
  • Isn't DELETE returning 404 the second time non-idempotent? Idempotency is about the server's state, not the response. After one DELETE or five, the resource is gone.
Q09Not marked yet1–3 yrsAsked sometimesConcept

REST vs GraphQL: what does GraphQL solve, and what does it make harder?

Say this first~25 sec to say

GraphQL exposes one endpoint and a typed schema; each client asks for exactly the fields it needs, across related data, and gets that shape back. That fixes over-fetching (more data than the screen needs) and under-fetching (several round trips per screen). In return, HTTP caching gets harder, expensive nested queries need limits, servers need batching to avoid N+1 queries, and simple CRUD rarely needs it.

Could you say it?
Full answer2 min read

One screen, two ways

A product page needs the product's name and price, plus the names of its three latest reviewers. With REST, that's typically two or three requests, each returning more fields than needed:

GET /products/42                 → every product field
GET /products/42/reviews?limit=3 → full review objects

With GraphQL, one request asks for exactly that shape:

query {
  product(id: 42) {
    name
    price
    reviews(last: 3) {
      author { name }
    }
  }
}
{ "data": { "product": { "name": "Electric Kettle", "price": 1299,
  "reviews": [ { "author": { "name": "Asha" } }, { "author": { "name": "Ravi" } }, { "author": { "name": "Priya" } } ] } } }
RESTGraphQL
EndpointsMany: one per resourceOne: /graphql
Response shapeFixed by the serverChosen by the client
Over- and under-fetchingCommonAvoided
HTTP caching (CDNs, browsers)Natural for GETHarder: mostly POST requests
ErrorsHTTP status codesUsually 200, with an errors array
Server-side risksFewDeep, expensive queries; N+1 resolvers without batching (question 31)
Best forSimple CRUD, public APIs, caching-heavy readsMany client types (web, mobile) with different data needs

Follow-ups

  • How do you stop a malicious, deeply nested query? Limit query depth and complexity, add timeouts, and allow only pre-approved queries for public clients.
  • What is a resolver? The function that fetches one field's data. Without a batching layer such as DataLoader, resolving each author separately causes N+1 queries.
Q10Not marked yetFresherAsked oftenPractical

Design the REST endpoints for a todo app with users: URLs, methods and the status code each returns

Say this first~30 sec to say

Model two resources, users and their todos, with plural nouns and nesting for ownership. GET /users/:userId/todos lists a user's todos (200); POST to the same URL creates one (201, with the new todo and a Location header); GET /users/:userId/todos/:todoId fetches one (200 or 404); PATCH updates fields such as done (200); DELETE removes it (204, no body). Return 400 for invalid input, 401 if not logged in, 403 for someone else's todos, and version the API as /v1/… from the start.

Could you say it?
Full answer4 min read
MethodPathDoesSuccessCommon errors
GET/v1/users/:userId/todosList, with ?done=false&page=2200401, 403
POST/v1/users/:userId/todosCreate201 + Location400, 401, 403
GET/v1/users/:userId/todos/:todoIdRead one200401, 403, 404
PATCH/v1/users/:userId/todos/:todoIdUpdate some fields200400, 401, 403, 404
DELETE/v1/users/:userId/todos/:todoIdDelete204401, 403, 404
JavaScript
import express from "express";

const app = express();
app.use(express.json());
const todos = [];                                    // { id, userId, title, done }
let nextId = 1;

const v1 = express.Router({ mergeParams: true });

v1.use((req, res, next) => {                         // stands in for real auth (question 26)
  const current = Number(req.get("X-User-Id"));
  if (!current) return res.sendStatus(401);
  if (current !== Number(req.params.userId)) return res.sendStatus(403);
  next();
});

v1.get("/", (req, res) => {
  let list = todos.filter((t) => t.userId === Number(req.params.userId));
  if (req.query.done !== undefined) list = list.filter((t) => String(t.done) === req.query.done);
  res.json(list);
});

v1.post("/", (req, res) => {
  const title = req.body?.title?.trim();
  if (!title) return res.status(400).json({ error: "title is required" });
  const todo = { id: nextId++, userId: Number(req.params.userId), title, done: false };
  todos.push(todo);
  res.status(201).location(`/v1/users/${todo.userId}/todos/${todo.id}`).json(todo);
});

const findTodo = (req) =>
  todos.find((t) => t.id === Number(req.params.todoId) && t.userId === Number(req.params.userId));

v1.patch("/:todoId", (req, res) => {
  const todo = findTodo(req);
  if (!todo) return res.sendStatus(404);
  if ("done" in req.body && typeof req.body.done !== "boolean") {
    return res.status(400).json({ error: "done must be true or false" });
  }
  Object.assign(todo, { title: req.body.title ?? todo.title, done: req.body.done ?? todo.done });
  res.json(todo);
});

v1.delete("/:todoId", (req, res) => {
  const todo = findTodo(req);
  if (!todo) return res.sendStatus(404);
  todos.splice(todos.indexOf(todo), 1);
  res.sendStatus(204);
});

app.use("/v1/users/:userId/todos", v1);
export default app;

Design choices to say out loud

  • Nouns, not verbs: the method says what happens; the path names the thing.
  • Nesting shows ownership, but stop at one or two levels. /todos/:todoId alone is fine if the server checks ownership from the token.
  • Filtering, sorting and paging go in query parameters: ?done=false&sort=-createdAt&page=2.
  • Versioning: /v1 in the path is the simplest to see and route; a header (Accept: application/vnd.todo.v2+json) is the alternative.

Follow-ups

  • req.params vs req.query vs req.body? Path segments (:todoId), the query string (?done=false), and the parsed request body. All of them are user input, so validate all of them.
  • Why mergeParams: true? So the router can read :userId, which is defined on the path where the router is mounted.
Q11Not marked yetFresherAsked oftenPractical

Add page-and-limit pagination to GET /products in Express, returning the items with a total count

Say this first~25 sec to say

Read page and limit from req.query, convert and validate them, and cap limit, say at 100. Skip (page − 1) × limit items and return that slice plus metadata: page, limit, total and totalPages. In a database, that's LIMIT … OFFSET … with a stable ORDER BY. Offset paging slows on deep pages and can skip rows when data changes; cursor pagination (?after=lastId) fixes both.

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

const app = express();
const products = Array.from({ length: 23 }, (_, i) => ({ id: i + 1, name: `Product ${i + 1}` }));

app.get("/products", (req, res) => {
  const page = Number(req.query.page ?? 1);
  const limit = Math.min(Number(req.query.limit ?? 10), 100);       // never let a client ask for everything
  if (!Number.isInteger(page) || page < 1 || !Number.isInteger(limit) || limit < 1) {
    return res.status(400).json({ error: "page and limit must be positive integers" });
  }

  const total = products.length;
  const skip = (page - 1) * limit;
  res.json({
    items: products.slice(skip, skip + limit),
    page,
    limit,
    total,
    totalPages: Math.ceil(total / limit),
  });
});

export default app;
RequestReturns
/productsItems 1–10, page: 1, limit: 10, total: 23, totalPages: 3
/products?page=3&limit=10Items 21–23
/products?page=2&limit=5Items 6–10, totalPages: 5
/products?page=0400
/products?limit=5000Capped at 100 items

The SQL version

SQL
SELECT id, name FROM products ORDER BY id LIMIT 10 OFFSET 20;   -- page 3
SELECT COUNT(*) FROM products;                                  -- for total

Without ORDER BY, the database may return rows in any order, so pages can overlap or skip rows.

Offset vs cursor pagination

Offset (?page=3)Cursor (?after=20)
Jump to any pageYesNo: next and previous only
Speed on deep pagesSlows: the database still reads and skips the earlier rowsConstant: WHERE id > 20 ORDER BY id LIMIT 10 uses an index
New rows added while pagingRows shift; some are skipped or repeatedStable
Good forAdmin tables with page numbersInfinite scroll, feeds, large datasets

Follow-ups

  • Is COUNT(*) expensive? On huge tables, yes. Some APIs drop the exact total, return an estimate, or just say whether there's a next page.
  • How would you return the next page's link? Include it in the response ("next": "/products?page=3&limit=10") or in a Link header.

03Node.js runtime

0/5 got it

Q12Not marked yetFresherAsked very oftenConcept

Node.js runs your JavaScript on a single thread: how does it still handle many requests at once?

Say this first~30 sec to say

Node runs your JavaScript on one thread, but that thread almost never waits. When code starts I/O, such as a database query, an HTTP call or a file read, Node hands it to the operating system or libuv's thread pool and moves straight on to the next request. When the I/O finishes, its callback is queued and the event loop runs it once the thread is free. So thousands of requests can wait on I/O at once.

Could you say it?
Full answer2 min read
JavaScript
import express from "express";

const app = express();
const fakeDbQuery = () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1000));

app.get("/orders", async (req, res) => {
  res.json(await fakeDbQuery());      // while this waits, the thread serves other requests
});

const server = app.listen(3000, async () => {
  const start = Date.now();
  await Promise.all(Array.from({ length: 10 }, () => fetch("http://localhost:3000/orders")));
  console.log(`10 requests took ${Date.now() - start} ms`);   // about 1000, not 10,000
  server.close();
});

What's actually happening

          your JavaScript (one thread)
                    │
                    ▼
            ┌───────────────┐   callbacks ready to run
            │  event loop   │◀──────────────────────────┐
            └───────┬───────┘                           │
                    │ starts I/O, doesn't wait          │
                    ▼                                   │
   ┌──────────────────────────────────────┐             │
   │ libuv                                │             │
   │  network sockets → OS (epoll/kqueue)  │ ── done ────┘
   │  files, DNS, crypto, zlib → 4 threads │
   └──────────────────────────────────────┘
  • Network I/O uses the operating system's own non-blocking mechanisms, so no thread waits at all.
  • File system, DNS lookups, some crypto and zlib run on libuv's thread pool (4 threads by default, UV_THREADPOOL_SIZE to change it).
  • Your JavaScript always runs on the main thread, one callback at a time.

The catch: this only works while every callback is short. CPU-heavy work (image resizing, big loops, synchronous hashing) holds the single thread, and every other request waits (question 13).

Follow-ups

  • How does Node use several CPU cores? Run several processes: the cluster module or a process manager such as PM2, or several containers behind a load balancer. For CPU-heavy tasks inside one process, use worker_threads.
  • Is Node good for CPU-heavy work? Not on the main thread. It shines at I/O-heavy work: APIs, real-time apps, proxies.
Q13Not marked yetFresherAsked very oftenConcept

Blocking vs non-blocking code in Node: what is the difference, and what happens when the event loop is blocked?

Say this first~30 sec to say

Blocking code makes the thread wait until it finishes: fs.readFileSync, a long loop, a huge JSON.parse, a synchronous hash. Non-blocking code starts the work and returns at once, with the result arriving later via a callback, promise or await. Node has one JavaScript thread, so blocking it stalls every request on the server, not just one. Use async APIs, and move heavy CPU work to worker threads or another service.

Could you say it?
Full answer2 min read

Seeing the stall

Every callback, including every other user's request, waits behind blocking code. A 10 ms timer shows it:

JavaScript
const start = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - start} ms`), 10);   // asked for 10 ms

const end = Date.now() + 500;
while (Date.now() < end) {}             // 500 ms of CPU work on the only thread
console.log("blocking work done");
blocking work done
timer fired after 500 ms

In a server, that timer could just as easily be the handler for someone's checkout request.

Blocking and non-blocking versions of the same work

JavaScript
import { readFileSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";

writeFileSync("orders.csv", "id,total\n1,499\n2,1299\n");

const a = readFileSync("orders.csv", "utf8");          // blocks until the whole file is read
const b = await readFile("orders.csv", "utf8");        // other callbacks run while it reads
console.log(a === b);                                  // true: same data, different waiting
Blocking (avoid in request handlers)Non-blocking alternative
fs.readFileSync, fs.writeFileSyncfs/promises readFile, writeFile, or streams
crypto.pbkdf2Sync, bcrypt.hashSynccrypto.pbkdf2, bcrypt.hash (thread pool)
A CPU-heavy loop over a big datasetworker_threads, a job queue (question 37), or another service
JSON.parse of a 200 MB stringStream-parse it, or process it in a worker

Synchronous APIs are fine at startup, for example reading a config file before the server starts listening.

Follow-ups

  • How do you find what's blocking? Measure event-loop lag (perf_hooks.monitorEventLoopDelay), and profile with node --cpu-prof or Clinic.js.
  • Does async make code non-blocking? No. An async function that runs a long loop still blocks; only the waiting parts, the awaits on real I/O, free the thread.
Q14Not marked yet1–3 yrsAsked oftenConcept

In what order do process.nextTick, Promise callbacks, setTimeout(fn, 0) and setImmediate run, and why?

Say this first~30 sec to say

After the current synchronous code, Node first drains the process.nextTick queue, then the promise microtask queue; only then does the event loop move on. setTimeout(fn, 0) runs in the timers phase and setImmediate in the check phase, which follows polling for I/O. So the order is: synchronous code, nextTick, promise callbacks, then setTimeout and setImmediate. From the main script those last two can come in either order; inside an I/O callback, setImmediate always runs first.

Could you say it?
Full answer2 min read
JavaScript
// order.cjs: run with `node order.cjs`
console.log("1: sync");
setTimeout(() => console.log("5: setTimeout"), 0);
setImmediate(() => console.log("6: setImmediate"));
Promise.resolve().then(() => console.log("4: promise"));
process.nextTick(() => console.log("3: nextTick"));
console.log("2: sync");
1: sync
2: sync
3: nextTick
4: promise
5: setTimeout      ← these two can swap places from run to run
6: setImmediate

Why the last two can swap: setTimeout(fn, 0) really means "at least 1 ms from now". If the loop reaches the timers phase before that millisecond has passed, the timer isn't due yet, so the check phase (setImmediate) goes first.

Inside an I/O callback the order is fixed

JavaScript
const fs = require("node:fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));   // always first here: check comes right after poll
});

The event loop's phases

   ┌─► timers          setTimeout, setInterval callbacks that are due
   │   pending         some deferred system callbacks
   │   poll            wait for and run I/O callbacks
   │   check           setImmediate callbacks
   └── close           'close' events
   (between every single callback: drain nextTick, then promise microtasks)

A twist in ES modules: in a .mjs file, or with "type": "module", Node 25 printed 4: promise before 3: nextTick for the same code. The module's top-level code itself runs inside a promise job, so the promise microtask queue is drained before Node gets back to the nextTick queue. Interviewers usually mean the CommonJS order above.

Follow-ups

  • Why can process.nextTick be dangerous? A recursive nextTick keeps the queue from ever emptying, so I/O never gets a turn and the server starves.
  • How does this compare with the browser? Browsers have microtasks and tasks too, but no nextTick or setImmediate; the Frontend kit's question 17 covers the browser's loop.
Q15Not marked yet1–3 yrsAsked sometimesPractical

Send a 2 GB file from an Express route without loading it into memory

Say this first~30 sec to say

Don't read the file with readFile. Open a readable stream with fs.createReadStream and pipe it into the response, which is a writable stream. The data flows in small chunks (64 KB each by default), so memory stays flat however big the file is. Use stream.pipeline, or res.sendFile, which streams for you, so errors and early disconnects clean up properly, while backpressure pauses reading whenever the client can't keep up. Set Content-Type, Content-Length and Content-Disposition.

Could you say it?
Full answer3 min read
JavaScript
import express from "express";
import fs from "node:fs";
import { stat } from "node:fs/promises";
import { pipeline } from "node:stream/promises";

const app = express();

app.get("/downloads/backup", async (req, res, next) => {
  const path = "./backup.tar.gz";
  try {
    const { size } = await stat(path);
    res.set({
      "Content-Type": "application/gzip",
      "Content-Length": size,
      "Content-Disposition": 'attachment; filename="backup.tar.gz"',
    });
    await pipeline(fs.createReadStream(path), res);    // chunk by chunk, with backpressure
  } catch (err) {
    if (!res.headersSent) next(err);                    // e.g. the file doesn't exist
    // otherwise the client disconnected mid-download; pipeline has already cleaned up
  }
});

export default app;

Measured on this route: streaming a 200 MB file raised the server's peak memory by about 60 MB of buffers, and an 800 MB file by only about 80 MB. Sending the 200 MB file with readFile first raised it by over 200 MB: the whole file, held in memory at once.

What backpressure means: a fast disk can read far faster than a slow mobile connection can receive. When the response's buffer fills up, write() returns false; pipeline then pauses the file stream until the response emits drain. Without that, the unsent data would pile up in memory anyway.

The kinds of stream

KindExample
Readablefs.createReadStream, an incoming HTTP request
Writablefs.createWriteStream, an HTTP response
DuplexA TCP socket
Transformzlib.createGzip(), a CSV parser

Compressing on the fly: add a transform in the middle: pipeline(fs.createReadStream(path), zlib.createGzip(), res).

Follow-ups

  • How would you support resumable downloads? Honour the Range header: answer 206 Partial Content and stream only the requested bytes, with createReadStream(path, { start, end }). res.sendFile does this for you.
  • Why not .pipe()? readable.pipe(res) doesn't forward errors or destroy the other stream on failure, which can leak file handles; pipeline does both.
Q16Not marked yetFresherAsked oftenConcept

CommonJS vs ES modules: how do require/module.exports and import/export differ in Node?

Say this first~30 sec to say

CommonJS is Node's original module system: require() loads a module synchronously, at runtime, and module.exports exposes values. ES modules are the JavaScript standard: import and export are static declarations, analysed before the code runs, which enables tree shaking, top-level await, and the same syntax as browsers. Node treats .mjs files, and .js files in a package with "type": "module", as ES modules, and .cjs files, or .js without that field, as CommonJS.

Could you say it?
Full answer2 min read
JavaScript
// price.cjs: CommonJS
function withGst(amount) {
  return Math.round(amount * 1.18);
}
module.exports = { withGst };

// checkout.cjs
const { withGst } = require("./price.cjs");     // synchronous, can sit anywhere, even inside an if
console.log(withGst(1000));                     // 1180
JavaScript
// price.mjs: an ES module
export function withGst(amount) {
  return Math.round(amount * 1.18);
}

// checkout.mjs
import { withGst } from "./price.mjs";          // static: hoisted, analysed before running
const rates = await import("./price.mjs");      // dynamic import, when you need it at runtime
console.log(withGst(1000), rates.withGst(500)); // 1180 590
CommonJSES modules
Syntaxrequire, module.exportsimport, export
LoadingSynchronous, at runtimeStatic structure, loaded asynchronously
Top-level awaitNoYes
Tree shaking by bundlersHardBuilt for it
File paths in importsExtension optionalExtension required for relative files: ./price.mjs
__dirname, __filenameAvailableUse import.meta.dirname, import.meta.filename
Default in Node.js without "type".mjs, or .js with "type": "module"

Mixing them: an ES module can import a CommonJS module (its module.exports becomes the default export). Recent Node versions can also require() an ES module, as long as it has no top-level await.

Follow-ups

  • Which should a new project use? ES modules: they're the standard, and most of the ecosystem now publishes them.
  • Why does import need the file extension? ES module resolution follows browser rules, where URLs are exact; Node doesn't guess extensions for relative imports.

04Express and middleware

0/3 got it

Q17Not marked yetFresherAsked very oftenConcept

What is middleware in Express, and what happens if one neither sends a response nor calls next()?

Say this first~30 sec to say

Middleware is a function, (req, res, next), that Express runs in the order you registered it for each matching request, before or around your route handlers. Each one can read or change req and res, end the request by sending a response, or call next() to pass control on; next(err) jumps to the error handlers. If a middleware neither responds nor calls next(), the request just hangs until the client or a proxy gives up with a timeout.

Could you say it?
Full answer2 min read
JavaScript
import express from "express";

const app = express();

app.use(express.json());                          // built-in: parses JSON bodies into req.body

app.use((req, res, next) => {                     // custom: runs for every request
  req.startedAt = Date.now();
  next();                                         // pass control on
});

function requireApiKey(req, res, next) {          // route-level: only where it's attached
  if (req.get("X-Api-Key") !== "secret-key") {
    return res.status(401).json({ error: "Missing or wrong API key" });   // ends the request here
  }
  next();
}

app.get("/public", (req, res) => res.json({ ok: true }));
app.get("/reports", requireApiKey, (req, res) => res.json({ revenue: 125000 }));

app.get("/stuck", (req, res, next) => {
  // forgot to respond or call next(): this request hangs
});

export default app;

GET /reports without the key stops at requireApiKey with a 401; with the key, it reaches the handler. GET /stuck never gets a response.

Order matters

  • express.json() must come before routes that read req.body, or req.body is undefined.
  • Authentication middleware must come before the routes it protects.
  • The 404 handler goes after all routes, and the error handler last of all (question 19).
KindExample
Application-levelapp.use(logger)
Router-levelrouter.use(requireAuth)
Route-specificapp.get("/admin", requireAdmin, handler)
Built-inexpress.json(), express.urlencoded(), express.static("public")
Third-partyhelmet (security headers), cors, morgan (logging)
Error-handling(err, req, res, next) => {...}, four parameters

Follow-ups

  • What happens if you call next() after sending a response? The next handler may try to send again, and Express throws ERR_HTTP_HEADERS_SENT. Return after responding.
  • How does next("route") differ? It skips the remaining handlers for the current route and moves to the next matching route.
Q18Not marked yetFresherAsked oftenPractical

Write an Express middleware that logs each request's method, URL, status code and time taken

Say this first~25 sec to say

Write a function (req, res, next) that records the start time with performance.now(), then listens for the response's finish event, which fires once the response has been sent, and logs req.method, req.originalUrl, res.statusCode and the elapsed milliseconds. Call next() straight away, so the request carries on. Register it with app.use before your routes, so it sees every request, including 404s and errors.

Could you say it?
Full answer2 min read
JavaScript
import express from "express";

function requestLogger(req, res, next) {
  const start = performance.now();
  res.on("finish", () => {                               // after the response is sent
    const ms = (performance.now() - start).toFixed(1);
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
  });
  next();                                                // don't wait: let the request continue
}

const app = express();
app.use(requestLogger);                                  // first, so it sees everything
app.get("/products", (req, res) => res.json([{ id: 1, name: "Kettle" }]));
app.get("/boom", () => {
  throw new Error("database down");
});

export default app;
GET /products 200 1.4ms
GET /missing 404 0.6ms
GET /boom 500 1.1ms

Why finish, not a log line after next()? next() returns long before an async handler has finished, so the status code wouldn't be known yet and the time would be wrong. finish fires when the last byte of the response has been handed to the network.

Making it production-ready

  • Log JSON, not plain text, so log tools can filter by field: { method, url, status, ms }.
  • Add a request ID (from an X-Request-Id header, or generate one) to every log line, to follow one request across services.
  • Listen for close too: it fires when the client disconnects before the response finishes.
  • In practice, use pino-http or morgan, which do all this efficiently.

Follow-ups

  • req.url or req.originalUrl? Inside a router, req.url is relative to where the router is mounted; originalUrl is the full path the client requested.
  • Should you log request bodies? Not by default: they can contain passwords, tokens and personal data.
Q19Not marked yet1–3 yrsAsked oftenPractical

Write a central error-handling middleware and make sure errors from async route handlers reach it

Say this first~25 sec to say

Register one error handler last, with four parameters, (err, req, res, next), which is how Express recognises it. It logs the error and sends consistent JSON: err.status for expected errors like 404, a generic 500 otherwise, never the stack trace. Express 5 forwards rejected promises from async handlers to it automatically. Express 4 doesn't, and the unhandled rejection crashes the process, so wrap handlers to call next(err).

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

class AppError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

const findOrder = async (id) => (id === "42" ? { id: 42, total: 1299 } : null);

const app = express();

app.get("/orders/:id", async (req, res) => {
  const order = await findOrder(req.params.id);
  if (!order) throw new AppError(404, "Order not found");    // Express 5 forwards this
  res.json(order);
});

app.get("/crash", async () => {
  throw new Error("connection refused at 10.0.3.7:5432");     // an unexpected bug
});

app.use((req, res) => res.status(404).json({ error: "Route not found" }));   // no route matched

app.use((err, req, res, next) => {                            // four parameters: the error handler
  const status = err.status ?? 500;
  if (status >= 500) console.error(err);                      // log real failures with the stack
  res.status(status).json({ error: status >= 500 ? "Something went wrong" : err.message });
});

export default app;
RequestResponse
GET /orders/42200 {"id":42,"total":1299}
GET /orders/7404 {"error":"Order not found"}
GET /crash500 {"error":"Something went wrong"}: the internal address stays in the logs
GET /nope404 {"error":"Route not found"}

Express 4: wrap async handlers

In Express 4, the same async handler's error never reaches your error handler. The rejected promise goes unhandled, and since Node 15 an unhandled rejection crashes the whole process by default, taking every other request down with it. The usual fix is a small wrapper:

JavaScript
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

app.get("/orders/:id", asyncHandler(async (req, res) => {
  const order = await findOrder(req.params.id);
  if (!order) throw new AppError(404, "Order not found");
  res.json(order);
}));

Rules for the error handler

  • Register it after every route and other middleware.
  • Never send stack traces or internal messages to clients in production; log them instead.
  • If the response has already started (res.headersSent), call next(err) and let Express close the connection.

Follow-ups

  • How does Express know it's an error handler? By the function's length: exactly four declared parameters.
  • What about errors outside requests, like a failed background job? Catch them where they happen; also listen for process.on("unhandledRejection") to log anything that slips through.

05Authentication and security

0/8 got it

Q20Not marked yetFresherAsked very oftenConcept

Authentication vs authorization: what is the difference?

Say this first~30 sec to say

Authentication answers "who are you?": the user proves their identity with a password, an OTP, a passkey or a token, and the server verifies it. Authorization answers "what are you allowed to do?": once the identity is known, the server checks permissions, such as whether this user may delete this order or open the admin panel. Authentication always comes first. A failed authentication is 401 Unauthorized; a failed authorization is 403 Forbidden, despite the confusing names.

Could you say it?
Full answer2 min read
AuthenticationAuthorization
QuestionWho are you?What may you do?
HappensFirst, at sign-in and on every request (via the session or token)After authentication, for every protected action
Based onCredentials: password, OTP, passkey, Google sign-inRoles, permissions, ownership, policies
Failure401 Unauthorized403 Forbidden
ExampleLogging in to a banking appOnly the account holder can see that account's statement

In code, they're usually two separate middlewares:

JavaScript
import express from "express";

const app = express();

function authenticate(req, res, next) {             // who are you?
  const userId = req.get("X-Demo-User");             // stands in for a real session or JWT check
  if (!userId) return res.status(401).json({ error: "Please sign in" });
  req.user = { id: userId, role: userId === "1" ? "admin" : "customer" };
  next();
}

function authorize(...roles) {                       // what may you do?
  return (req, res, next) =>
    roles.includes(req.user.role) ? next() : res.status(403).json({ error: "Not allowed" });
}

app.get("/orders", authenticate, (req, res) => res.json({ owner: req.user.id }));
app.delete("/products/:id", authenticate, authorize("admin"), (req, res) => res.sendStatus(204));

export default app;

Common authorization models

  • Role-based (RBAC): permissions attach to roles such as admin, seller and customer.
  • Ownership checks: a user may edit only their own resources, checked against the record's owner ID.
  • Attribute-based (ABAC): rules on attributes, such as "managers may approve expenses under ₹50,000 in their own department".

Follow-ups

  • Is OAuth authentication or authorization? OAuth 2.0 is about authorization (granting an app access to an API). OpenID Connect adds authentication on top (question 24).
  • Where do authorization checks belong? On the server, for every request. Hiding a button in the UI is not authorization.
Q21Not marked yetFresherAsked very oftenConcept

Session-based vs JWT-based authentication: how does each work, and what are the trade-offs?

Say this first~30 sec to say

With sessions, the server stores login state, in memory, a database or Redis, and the browser holds only a random session ID in a cookie, looked up on each request: logout is instant, but servers need that shared store. With JWTs, the server signs a token holding the user's ID and role, and verifies the signature without a lookup: easy to scale, but a token stays valid until it expires, so revoking it needs short expiry or refresh tokens.

Could you say it?
Full answer2 min read
SessionsJWTs
State livesOn the serverIn the token, carried by the client
Each requestLook up the session IDVerify the signature, no lookup
Logout and revoking accessDelete the session: immediateHard: the token works until exp
Scaling across serversNeeds a shared store (Redis)Any server with the key can verify
Size per requestA short IDLarger: the encoded claims
Changing a user's roleTakes effect at onceOnly when a new token is issued
Typical useClassic web apps on one domainAPIs, mobile apps, service-to-service calls

Access and refresh tokens, the usual follow-up

  • The access token is a JWT that expires quickly, say in 15 minutes, and is sent with every API call.
  • The refresh token lives longer, say 7 days, is stored securely (an HttpOnly cookie), and is used only to obtain new access tokens.
  • The server keeps refresh tokens in a database, so logging out, or blocking a stolen device, revokes the refresh token, and the access token dies within minutes.

Where to keep a JWT in the browser: an HttpOnly, Secure, SameSite cookie, so injected JavaScript can't read it. Storing it in localStorage means any XSS bug can steal it.

Follow-ups

  • Are JWTs stateless all the way? Not once you add a denylist or refresh tokens; most real systems end up with some server-side state either way.
  • Which would you choose? For a web app on one domain, sessions are simpler and safer. For mobile apps, third-party API clients or many microservices, JWTs fit better.
Q22Not marked yetFresherAsked very oftenConcept

What are the three parts of a JWT, and why shouldn't secrets go in its payload?

Say this first~30 sec to say

A JWT is three Base64URL parts joined by dots: the header (the algorithm, such as HS256), the payload (claims like sub, a role, iat and exp), and the signature, computed over the first two with a secret or private key. The signature makes the token tamper-proof, not secret: anyone holding it can decode the payload instantly, since it's only encoded. So never put passwords or personal data in it.

Could you say it?
Full answer2 min read
JavaScript
import jwt from "jsonwebtoken";

const SECRET = process.env.JWT_SECRET ?? "dev-only-secret-change-me";
const token = jwt.sign({ sub: "user_42", role: "customer" }, SECRET, { expiresIn: "15m" });

const [header, payload, signature] = token.split(".");
console.log(JSON.parse(Buffer.from(header, "base64url")));
// { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(payload, "base64url")));
// { sub: 'user_42', role: 'customer', iat: 1793088000, exp: 1793088900 }: anyone can read this

// Tampering: change the role, keep the old signature
const forgedPayload = Buffer.from(JSON.stringify({ sub: "user_42", role: "admin" })).toString("base64url");
try {
  jwt.verify(`${header}.${forgedPayload}.${signature}`, SECRET, { algorithms: ["HS256"] });
} catch (err) {
  console.log(err.message);   // invalid signature
}

The iat and exp values are Unix timestamps in seconds, so yours will differ; exp is 900 seconds (15 minutes) after iat.

Registered claims worth knowing

ClaimMeaning
subSubject: who the token is about, usually the user ID
expExpiry time; reject the token after it
iatIssued-at time
iss, audIssuer and intended audience; check both when several services share keys
jtiA unique token ID, useful for denylists

Signing choices: HS256 uses one shared secret to sign and verify, which suits a single backend. RS256 or ES256 sign with a private key and verify with a public key, so other services can verify tokens without being able to create them.

Security checklist

  • Always pass the expected algorithms to verify, and never accept alg: "none".
  • Use a long, random secret from an environment variable, never a hard-coded string.
  • Keep tokens short-lived, and send them only over HTTPS.

Follow-ups

  • What if the payload must be secret? Use JWE, the encrypted form of JWT, or simply keep the data on the server and put only an ID in the token.
  • Why Base64URL rather than Base64? It swaps + and / for - and _ and drops padding, so tokens are safe in URLs and headers.
Q23Not marked yetFresherAsked very oftenConcept

How should a backend store user passwords, and why hash them with a salt rather than encrypt them?

Say this first~30 sec to say

Never store passwords, or anything that can be turned back into them. Store the output of a deliberately slow password-hashing function: bcrypt, scrypt or Argon2, each using a unique random salt per password. Hashing is one-way, so a database leak doesn't reveal the passwords; with encryption, whoever gets the key gets every password at once. The salt makes identical passwords hash differently and defeats precomputed rainbow tables, and the slowness, set by a cost factor, makes mass guessing impractical.

Could you say it?
Full answer3 min read
JavaScript
import bcrypt from "bcrypt";

const hash1 = await bcrypt.hash("Pune@2024", 12);        // 12 = cost: 2^12 rounds of work
const hash2 = await bcrypt.hash("Pune@2024", 12);

console.log(hash1 === hash2);                              // false: a different salt each time
console.log(hash1.slice(0, 7));                            // $2b$12$: the algorithm and the cost
console.log(hash1.length);                                 // 60
console.log(await bcrypt.compare("Pune@2024", hash1));     // true
console.log(await bcrypt.compare("pune@2024", hash1));     // false

Inside a bcrypt hash (a real hash of "Pune@2024", split at the salt)

$2b$12$1jHdH1YaAVa8vev2xM1Vn.  32wP8po0cUnMQ2qjNStsIAoCU0NXduu
 │   │  └── 22-character salt ┘ └── 31-character hash ──────────┘
 │   └── cost factor: 2^12 rounds
 └── algorithm version

Everything needed to check a password, the algorithm, the cost and the salt, is stored inside the hash itself, so one column is enough.

ApproachA database leak meansVerdict
Plain textEvery password is exposedNever
EncryptionEvery password, once the key is found (it's on the same servers)No: encryption is reversible by design
A fast hash (MD5, SHA-256)Billions of guesses per second on a GPU; rainbow tables crack common ones instantlyNo
A fast hash with a saltNo rainbow tables, but guessing is still fastNo
A slow hash with a salt (bcrypt, scrypt, Argon2)Each guess is expensive; strong passwords surviveYes

Practical notes

  • Pick a cost that takes roughly 100–300 ms on your server, and raise it as hardware improves; rehash a user's password at their next login.
  • bcrypt only uses the first 72 bytes of the password. Argon2id, recommended by OWASP for new systems, has no such limit.
  • Node has crypto.scrypt built in, if you'd rather avoid a native dependency.
  • Compare with the library's compare, which runs in constant time, never with === on hashes.

Follow-ups

  • What is a pepper? An extra secret mixed into every password before hashing, kept outside the database (for example in a secrets manager), so a database-only leak isn't enough to start guessing.
  • What else protects accounts? Rate-limiting login attempts (question 27), locking out after repeated failures, and two-factor authentication.
Q24Not marked yet1–3 yrsAsked sometimesConcept

At a high level, what happens when a user clicks "Sign in with Google"?

Say this first~25 sec to say

Your app redirects the user to Google with your client ID, the scopes you want, a redirect URI and a random state. The user signs in and consents, and Google redirects back with a short-lived authorization code. Your server swaps that code, plus your client secret, for tokens, including an ID token: a signed JWT saying who the user is. You verify it, then start your own session.

Could you say it?
Full answer2 min read
Browser            Your server                       Google
   │  click "Sign in with Google"  │                       │
   │──────────────────────────────▶│                       │
   │  302 to accounts.google.com?client_id=…&scope=openid email&state=xyz&redirect_uri=…
   │◀──────────────────────────────│                       │
   │─────────────────── user signs in and consents ───────▶│
   │◀────────── 302 to your /callback?code=abc&state=xyz ──│
   │  GET /callback?code=abc&state=xyz                     │
   │──────────────────────────────▶│  POST code + client secret
   │                               │──────────────────────▶│
   │                               │◀── ID token (JWT) + access token
   │  your own session cookie      │  verify the ID token, find or create the user
   │◀──────────────────────────────│                       │

Why each piece is there

  • The code is exchanged server to server, so tokens never pass through the browser's address bar, and the client secret never leaves your server.
  • state is a random value you check on the way back, which stops attackers from feeding you their own login (a CSRF attack on the login flow).
  • PKCE adds a one-time secret to the exchange, so a stolen code is useless on its own; it's required for mobile and single-page apps, and recommended everywhere.
  • Verify the ID token: its signature (against Google's published keys), aud (your client ID), iss (Google) and exp.
OAuth 2.0OpenID Connect (OIDC)
PurposeAuthorization: let an app access an API on the user's behalfAuthentication: tell the app who the user is
Main tokenAccess token, for calling APIsID token, a JWT with the user's identity
ExampleA photo app reading your Google Drive"Sign in with Google" on any site

Follow-ups

  • Why not just use Google's access token as your app's login? It's meant for Google's APIs, not as proof of identity to your app. The ID token is the identity proof; then issue your own session.
  • What should you store about the user? Google's stable user ID (the sub claim), not the email, which can change.
Q25Not marked yetFresherAsked very oftenConcept

What is SQL injection (and its NoSQL equivalent), and how do you prevent it in API code?

Say this first~25 sec to say

Injection happens when untrusted input is pasted into a query string, so it can change the query's structure: typing ' OR '1'='1 can turn WHERE email = '…' into a condition that's always true. Use parameterised queries: the query and the values travel separately, so input never becomes SQL. NoSQL has the same bug, such as {"$ne": null} reaching a MongoDB filter; validate input types first.

Could you say it?
Full answer2 min read
JavaScript
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)");
db.prepare("INSERT INTO users (email) VALUES (?), (?)").run("[email protected]", "[email protected]");

function findUserUnsafe(email) {
  return db.prepare(`SELECT id, email FROM users WHERE email = '${email}'`).all();   // input becomes SQL
}

function findUserSafe(email) {
  return db.prepare("SELECT id, email FROM users WHERE email = ?").all(email);      // input stays data
}

const attack = "' OR '1'='1";
console.log(JSON.stringify(findUserUnsafe(attack)));
// [{"id":1,"email":"[email protected]"},{"id":2,"email":"[email protected]"}]: every user
console.log(JSON.stringify(findUserSafe(attack)));
// []: no user has that strange email

The unsafe version builds WHERE email = '' OR '1'='1', which matches every row. The safe version sends the query and the value separately, so the database compares the whole string as an email.

The NoSQL version

JavaScript
// Request body: { "email": "[email protected]", "password": { "$ne": null } }
const user = await users.findOne({ email: req.body.email, password: req.body.password });
// The query becomes "password is not null": it matches without knowing the password

The fix is to insist on the expected types before querying (typeof password === "string"), ideally with a schema validator such as Zod or Joi, and never to build filters straight from the request body.

Prevention checklist

  • Parameterised queries or prepared statements, everywhere. ORMs do this by default, but their "raw query" features can still be misused.
  • Validate input against a schema: types, lengths, formats, allowed values.
  • Never put user input into table or column names; if a client chooses the sort column, pick from an allow-list.
  • Give the app's database user only the permissions it needs, so a successful injection can do less damage.

Follow-ups

  • Doesn't escaping quotes fix it? Escaping is error-prone across character sets and edge cases. Parameters remove the problem rather than patching it.
  • What's the related browser-side attack? Cross-site scripting (XSS): untrusted input rendered as HTML or script. The fix there is output encoding, which frameworks such as React do by default.
Q26Not marked yetFresherAsked oftenPractical

Write Express middleware that verifies a JWT from the Authorization header and lets only admins reach a route

Say this first~25 sec to say

Read the Authorization header and check it has the form Bearer <token>. Verify the token with jwt.verify, passing your secret and the allowed algorithm; if it's missing, invalid or expired, respond 401. Otherwise attach the decoded claims to req.user and call next(). A second middleware, requireRole("admin"), checks req.user.role and responds 403 if it doesn't match. Chain both on the admin route.

Could you say it?
Full answer3 min read
JavaScript
import express from "express";
import jwt from "jsonwebtoken";

const SECRET = process.env.JWT_SECRET ?? "dev-only-secret-change-me";

export function requireAuth(req, res, next) {
  const [scheme, token] = (req.get("Authorization") ?? "").split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ error: "Missing bearer token" });
  }
  try {
    req.user = jwt.verify(token, SECRET, { algorithms: ["HS256"] });   // checks signature and exp
    next();
  } catch (err) {
    const message = err.name === "TokenExpiredError" ? "Token expired" : "Invalid token";
    res.status(401).json({ error: message });
  }
}

export function requireRole(role) {
  return (req, res, next) => {
    if (req.user?.role !== role) return res.status(403).json({ error: "Admins only" });
    next();
  };
}

const app = express();
app.get("/admin/reports", requireAuth, requireRole("admin"), (req, res) => {
  res.json({ revenue: 125000, viewedBy: req.user.sub });
});

export default app;
RequestResponse
No Authorization header401 Missing bearer token
Bearer not-a-real-token401 Invalid token
A token signed with a different secret401 Invalid token
A valid but expired token401 Token expired
A valid token with role: "customer"403 Admins only
A valid token with role: "admin"200 with the report

What to point out

  • 401 vs 403: a bad or missing token is an authentication failure; a valid user without the role is an authorization failure (question 20).
  • algorithms: ["HS256"] stops "algorithm confusion" attacks, where a forged token claims a different algorithm.
  • The role comes from the verified token, never from the request body or a query parameter.
  • Generic messages: don't tell clients which part of the check failed beyond what they need.

Follow-ups

  • How would you check ownership, not just a role? Load the resource, compare its owner ID with req.user.sub, and return 403 (or 404) if they differ.
  • The user's role changed: how does the token find out? It doesn't until it's reissued, which is why access tokens should be short-lived (question 21).
Q27Not marked yet1–3 yrsAsked sometimesPractical

Implement per-IP rate limiting in Express without a library (e.g. 100 requests per 15 minutes)

Say this first~25 sec to say

Keep a counter per client IP for the current window. On each request, start a new window if the old one has expired, then increment. Over the limit, respond 429 Too Many Requests with a Retry-After header; otherwise call next(). That's a fixed-window limiter. An in-memory Map only works on one server; with several, keep the counters in a shared store like Redis (INCR with an expiry).

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

export function rateLimit({ limit = 100, windowMs = 15 * 60 * 1000 } = {}) {
  const hits = new Map();                              // ip -> { count, resetAt }

  setInterval(() => {                                   // forget expired windows now and then
    const now = Date.now();
    for (const [ip, entry] of hits) if (entry.resetAt <= now) hits.delete(ip);
  }, windowMs).unref();                                 // unref: don't keep the process alive just for this

  return (req, res, next) => {
    const now = Date.now();
    let entry = hits.get(req.ip);
    if (!entry || entry.resetAt <= now) {
      entry = { count: 0, resetAt: now + windowMs };    // a fresh window
      hits.set(req.ip, entry);
    }
    entry.count += 1;

    res.set("RateLimit-Remaining", String(Math.max(0, limit - entry.count)));
    if (entry.count > limit) {
      res.set("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
      return res.status(429).json({ error: "Too many requests, try again later" });
    }
    next();
  };
}

const app = express();
app.set("trust proxy", 1);           // behind one proxy or load balancer: use X-Forwarded-For for req.ip
app.use("/api/login", rateLimit({ limit: 5, windowMs: 60_000 }));   // stricter on login
app.post("/api/login", (req, res) => res.json({ ok: true }));

export default app;

With a limit of 5 per minute, the first five requests return 200 and the sixth returns 429 with Retry-After: 60.

With several servers: Redis

JavaScript
async function isAllowed(redis, ip, limit, windowSeconds) {
  const key = `ratelimit:${ip}`;
  const count = await redis.incr(key);                 // atomic across every server
  if (count === 1) await redis.expire(key, windowSeconds);
  return count <= limit;
}

Algorithms compared

AlgorithmHowTrade-off
Fixed windowCount per window that resets on the clockSimple; allows a burst of 2× the limit across a window boundary
Sliding windowCount over the last N minutes, continuouslySmoother; a little more memory or computation
Token bucketTokens refill at a steady rate; each request spends oneAllows short bursts, enforces an average rate; common in API gateways

Follow-ups

  • Why is trust proxy needed? Behind a load balancer, every request arrives from the balancer's IP. Trusting the proxy tells Express to read the client's IP from X-Forwarded-For, but only set it for proxies you control, or clients can spoof that header.
  • Limit by what? IP for anonymous traffic; user ID or API key for authenticated traffic, since many users can share one IP behind a mobile carrier or office network.

06Databases from the application side

0/4 got it

Q28Not marked yetFresherAsked very oftenConcept

SQL vs NoSQL: how would you choose a database for a new project?

Say this first~30 sec to say

Choose from the shape of the data and how it's accessed. A relational SQL database, such as PostgreSQL or MySQL, suits structured data with relationships and strong consistency needs: orders, payments, inventory, where joins, constraints and multi-row transactions matter. NoSQL suits flexible or fast-changing schemas, huge write volumes or simple key-based access at scale: MongoDB for documents, Redis for key-value caching, Cassandra for write-heavy workloads. When unsure, a relational database is the safer default.

Could you say it?
Full answer2 min read
SQL (relational)NoSQL
Data modelTables with a fixed schemaDocuments, key-value pairs, wide columns or graphs
RelationshipsJoins and foreign keysUsually embedded or denormalised
ConsistencyACID transactions across rows and tablesVaries; many favour availability and eventual consistency
ScalingMostly vertical, plus read replicas; sharding is hardMany are built to scale out across machines
QueryingSQL: flexible, ad-hoc queriesOptimised for known access patterns
ExamplesPostgreSQL, MySQL, SQL ServerMongoDB, Redis, Cassandra, DynamoDB, Neo4j

The families of NoSQL

TypeStoresGood for
Document (MongoDB)JSON-like documentsProduct catalogues, user profiles, varied content
Key-value (Redis, DynamoDB)Values looked up by keyCaches, sessions, counters, shopping carts
Wide-column (Cassandra)Rows with flexible columns, partitioned across nodesTime series, event logs, very high write rates
Graph (Neo4j)Nodes and relationshipsSocial networks, recommendations, fraud rings

Questions to ask before choosing

  1. Do I need transactions across several records, such as debiting one account and crediting another? That points to SQL.
  2. Are my queries known in advance, or will people ask new questions of the data? Ad-hoc questions favour SQL.
  3. How big, and how write-heavy, will it get? Massive scale-out favours some NoSQL databases.
  4. How stable is the schema? A schema that changes constantly favours documents, although PostgreSQL's JSONB columns cover much of that too.

Follow-ups

  • Can you use both? Yes, and most companies do: PostgreSQL as the system of record, Redis for caching and sessions, Elasticsearch for search.
  • What is the CAP theorem? During a network partition, a distributed database must choose between consistency and availability; it can't guarantee both.
Q29Not marked yetFresherAsked oftenConcept

What does an ORM or ODM give you over raw queries, and what does it cost?

Say this first~25 sec to say

An ORM (Prisma, Sequelize, Hibernate) or ODM (Mongoose) maps tables or collections to objects in your code, so you write user.orders instead of SQL strings. You gain speed of development, safe parameters by default, migrations and types. You lose control: generated queries can be inefficient, N+1 queries creep in, and complex reports are awkward. Most teams use one and drop to raw SQL for the hard queries.

Could you say it?
Full answer2 min read

The same query, both ways

-- Raw SQL: explicit and fast, but you map rows to objects yourself
SELECT u.id, u.name, COUNT(o.id) AS orders
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE u.city = $1
GROUP BY u.id, u.name;

// Prisma (an ORM): reads like code, typed results, parameters handled for you
const users = await prisma.user.findMany({
  where: { city: "Pune" },
  include: { _count: { select: { orders: true } } },
});
ORM or ODMRaw SQL (or a query builder)
Speed of writing CRUDFastSlower
Protection from injectionBuilt inUp to you: always use parameters
Type safetyGenerated types (Prisma, TypeORM)Manual
MigrationsUsually built inA separate tool
Complex reporting queriesAwkwardNatural
Performance visibilityHidden: you must log the generated SQLExactly what you wrote
Lock-inTo the ORM's way of workingTo SQL, which outlives every ORM

The middle ground: query builders such as Knex or Drizzle let you compose SQL in code with parameters, without hiding what runs.

Follow-ups

  • How do you spot ORM performance problems? Log the generated SQL in development, count queries per request, and run EXPLAIN on slow ones.
  • What is the difference between an ORM and an ODM? An ORM maps relational rows and tables; an ODM maps documents in a document database.
Q30Not marked yetFresherAsked very oftenConcept

A query that finds a user by email is slow: how would an index help, and what does an index cost?

Say this first~30 sec to say

Without an index, the database does a full table scan, checking every row's email: O(n). An index on email is a separate sorted structure, usually a B-tree, mapping each email to its row, so a lookup takes a few steps: O(log n). The costs: extra storage, and slower inserts, updates and deletes, since every index must be updated too. Index the columns you often filter, join or sort on.

Could you say it?
Full answer2 min read
JavaScript
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, name TEXT)");
const insert = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)");
db.exec("BEGIN");
for (let i = 0; i < 200_000; i++) insert.run(`user${i}@example.com`, `User ${i}`);
db.exec("COMMIT");

const plan = () =>
  db.prepare("EXPLAIN QUERY PLAN SELECT id FROM users WHERE email = ?").all("x").map((row) => row.detail);
const timeLookup = () => {
  const start = performance.now();
  db.prepare("SELECT id FROM users WHERE email = ?").get("[email protected]");
  return `${(performance.now() - start).toFixed(2)} ms`;
};

console.log(plan(), timeLookup());
// [ 'SCAN users' ] 3.01 ms: every row is read

db.exec("CREATE UNIQUE INDEX idx_users_email ON users (email)");
console.log(plan(), timeLookup());
// [ 'SEARCH users USING COVERING INDEX idx_users_email (email=?)' ] 0.01 ms

The plan changes from SCAN (read every row) to SEARCH using the index. With 200,000 rows that's milliseconds against microseconds; with 200 million, it's the difference between a working page and a timeout. Your timings will differ, but the gap won't.

What an index costs

CostWhy
StorageA second copy of the column's values, plus pointers to the rows
Slower writesEvery INSERT, UPDATE of the column and DELETE must update each index too
PlanningToo many overlapping indexes make the optimiser's job harder

Rules of thumb: index columns used in WHERE, JOIN and ORDER BY on large tables; avoid indexing tiny tables or columns with very few distinct values, such as a boolean; and check with EXPLAIN rather than guessing.

Follow-ups

  • Clustered vs non-clustered indexes? The SQL & DBMS kit, question 36, covers how they're stored.
  • When can't a query use the index? When it wraps the column in a function, such as WHERE LOWER(email) = ?, or uses a leading wildcard, LIKE '%@gmail.com' (SQL & DBMS kit, question 37).
Q31Not marked yet1–3 yrsAsked sometimesConcept

What is the N+1 query problem, and how do you fix it?

Say this first~25 sec to say

It's running one query for a list, then one more per item: 1 query for 50 orders, then 50 for their customers, 51 round trips where 1 or 2 would do. It hides in ORM lazy loading and in loops of await calls. Fix it with a join, eager loading (include, populate), or batching: collect the IDs and fetch them all with one WHERE id IN (…) query.

Could you say it?
Full answer3 min read
JavaScript
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
  CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total INTEGER);
`);
const names = ["Asha", "Ravi", "Priya", "Arjun", "Meera"];
names.forEach((name, i) => db.prepare("INSERT INTO customers VALUES (?, ?)").run(i + 1, name));
for (let i = 1; i <= 50; i++) db.prepare("INSERT INTO orders VALUES (?, ?, ?)").run(i, (i % 5) + 1, i * 100);

let queries = 0;
const query = (sql, ...params) => {
  queries += 1;
  return db.prepare(sql).all(...params);
};

// N+1: one query for the orders, then one per order
queries = 0;
for (const order of query("SELECT * FROM orders")) {
  order.customer = query("SELECT name FROM customers WHERE id = ?", order.customer_id)[0].name;
}
console.log(`N+1: ${queries} queries`);         // N+1: 51 queries

// Batching: one query for the orders, one for all their customers
queries = 0;
const orders = query("SELECT * FROM orders");
const ids = [...new Set(orders.map((o) => o.customer_id))];
const customers = query(`SELECT id, name FROM customers WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids);
const byId = new Map(customers.map((c) => [c.id, c.name]));
orders.forEach((o) => (o.customer = byId.get(o.customer_id)));
console.log(`Batched: ${queries} queries`);     // Batched: 2 queries

// A join: one query
queries = 0;
query("SELECT o.id, o.total, c.name FROM orders o JOIN customers c ON c.id = o.customer_id");
console.log(`Join: ${queries} query`);          // Join: 1 query

How to spot it: log every query in development and count them per request. A page whose query count grows with the number of items on it has an N+1.

FixHowNote
JoinOne query returns orders with their customersBest for one-to-one or many-to-one data
Eager loadinginclude: { customer: true } (Prisma), populate("customer") (Mongoose)The ORM issues a join or an IN query for you
BatchingCollect IDs, then one WHERE id IN (...)DataLoader does this automatically in GraphQL servers

Follow-ups

  • Why is 51 small queries worse than 1 bigger one? Each query pays a network round trip, parsing and planning; at 2 ms each, 51 queries add 100 ms of pure overhead.
  • Can a join be worse? Joining many one-to-many relations at once multiplies rows (a "cartesian explosion"); two batched queries can then be faster.

07Caching and performance

0/3 got it

Q32Not marked yetFresherAsked oftenConcept

What is caching, and at which layers can a web app cache data?

Say this first~25 sec to say

Caching keeps a copy of data somewhere faster, so repeated requests skip slow work. The layers: the browser (via Cache-Control headers), a CDN near users, the app's memory, a shared cache like Redis, and the database's own buffers. A hit serves the copy; a miss fetches from the source and usually stores it. Every cache needs an expiry (TTL) or an invalidation rule, or it serves stale data.

Could you say it?
Full answer3 min read
LayerWhat it cachesControlled by
BrowserStatic files, API responsesCache-Control, ETag, Last-Modified headers
CDN (Cloudflare, CloudFront)Images, scripts, public pages, near the userThe same headers, plus CDN rules
Reverse proxy (Nginx, Varnish)Whole responsesProxy configuration
Application memoryHot config, small lookup tablesYour code (lost on restart; not shared between servers)
Shared cache (Redis, Memcached)Query results, sessions, rendered fragmentsYour code, with TTLs
DatabaseRecently used data pages, query plansThe database itself

Telling the browser and CDN what to cache

JavaScript
import express from "express";

const app = express();

app.get("/assets/logo.svg", (req, res) => {
  res.set("Cache-Control", "public, max-age=31536000, immutable");   // a year: the file name changes on deploy
  res.type("image/svg+xml").send("<svg xmlns='http://www.w3.org/2000/svg'/>");
});

app.get("/api/categories", (req, res) => {
  res.set("Cache-Control", "public, max-age=300");     // shared caches may reuse it for 5 minutes
  res.json(["Electronics", "Home & Kitchen", "Stationery"]);
});

app.get("/api/me", (req, res) => {
  res.set("Cache-Control", "private, no-store");      // personal data: never stored in shared caches
  res.json({ name: "Asha" });
});

export default app;

Express also adds an ETag to responses. When a browser sends that value back in If-None-Match and nothing has changed, Express replies 304 Not Modified with an empty body, saving the download.

The hard parts

  • Invalidation: deciding when a cached copy is out of date. Short TTLs bound the staleness; deleting keys on writes keeps it fresh (question 34).
  • Stampedes: when a popular key expires, many requests miss at once and all hit the database. A lock, or refreshing early, prevents it.
  • What not to cache: personal or permission-dependent data in shared caches, unless the cache key includes the user.

Follow-ups

  • What's a cache hit ratio? Hits ÷ total lookups. A low ratio means the cache costs more than it saves.
  • Write-through vs cache-aside? Write-through updates the cache on every write; cache-aside loads into the cache only on a read miss (question 34).
Q33Not marked yetFresherAsked oftenConcept

What is Redis, and what do backends typically use it for?

Say this first~30 sec to say

Redis is an in-memory data store: data lives in RAM, so reads and writes take well under a millisecond, and it offers lists, sets, sorted sets, hashes and streams, with an expiry on any key. Backends use it as a cache, a shared session store, for rate limiting with atomic counters, leaderboards with sorted sets, and queues. It can persist to disk, but is treated as fast rather than durable.

Could you say it?
Full answer2 min read
UseRedis featureExample commands
CachingStrings with a TTLSET product:42 "{…}" EX 300, GET product:42
SessionsHashes or strings with a TTLSET session:abc "{…}" EX 86400
Rate limitingAtomic countersINCR ratelimit:1.2.3.4, EXPIRE ratelimit:1.2.3.4 60
LeaderboardsSorted setsZINCRBY scores 10 asha, ZREVRANGE scores 0 9 WITHSCORES
Queues and background jobsLists and streamsLPUSH jobs "{…}", BRPOP jobs 0; libraries such as BullMQ
Real-time messagesPub/subPUBLISH orders:new "42"
DeduplicationSetsSADD seen-emails [email protected]

Why it's fast: everything is in memory, the data structures are simple and efficient, and commands run one at a time on a single thread, so each command is atomic with no locks to manage.

What to remember about it

  • Memory is the limit: set maxmemory and an eviction policy, such as evicting the least recently used keys.
  • Persistence is optional: snapshots (RDB) and an append-only log (AOF) reduce loss on restart, but don't keep your only copy of important data in it.
  • Single-threaded command execution: one slow command, such as KEYS * on millions of keys, blocks everyone. Use SCAN instead.

Follow-ups

  • Redis or Memcached? Memcached is a simpler, multi-threaded cache of strings. Redis adds data structures, persistence, replication and pub/sub.
  • How do you scale Redis? Replicas for reads and failover (Sentinel), and Redis Cluster to shard data across nodes.
Q34Not marked yet1–3 yrsAsked sometimesPractical

Add Redis caching with a TTL to a slow GET /products/:id endpoint, and keep it correct when a product is updated

Say this first~30 sec to say

Use the cache-aside pattern. In the GET handler, first try redis.get("product:<id>"); on a hit, return the parsed JSON. On a miss, load the product from the database, store it with an expiry, set(key, JSON.stringify(product), { EX: 300 }), and return it. When a product is updated, write to the database first, then delete its cache key, so the next read repopulates it with fresh data. The TTL is a safety net in case an invalidation is ever missed.

Could you say it?
Full answer3 min read
JavaScript
import express from "express";

export function createApp({ redis, db }) {
  const app = express();
  app.use(express.json());
  const key = (id) => `product:${id}`;

  app.get("/products/:id", async (req, res) => {
    const cached = await redis.get(key(req.params.id));
    if (cached) return res.set("X-Cache", "HIT").json(JSON.parse(cached));

    const product = await db.findProduct(req.params.id);            // the slow part
    if (!product) return res.sendStatus(404);
    await redis.set(key(req.params.id), JSON.stringify(product), { EX: 300 });   // 5-minute TTL
    res.set("X-Cache", "MISS").json(product);
  });

  app.patch("/products/:id", async (req, res) => {
    const product = await db.updateProduct(req.params.id, req.body); // 1. the source of truth
    if (!product) return res.sendStatus(404);
    await redis.del(key(req.params.id));                              // 2. drop the stale copy
    res.json(product);
  });

  return app;
}

// In production:
//   import { createClient } from "redis";
//   const redis = await createClient({ url: process.env.REDIS_URL }).connect();
//   createApp({ redis, db }).listen(3000);
StepResponseDatabase queries
GET /products/42MISS, from the database1
GET /products/42 againHIT, from Redis0
PATCH /products/42 with a new priceUpdated; cache key deleted1
GET /products/42MISS: the new price is loaded and cached1

Why delete instead of updating the cache? Deleting is simpler and avoids a race: if two updates land close together, writing values into the cache could leave the older one there. Deleting means the next read always reloads the latest data.

Edge cases worth mentioning

  • Redis down: treat cache errors as misses (catch them and read from the database), so the cache can never take the site down.
  • Missing products: if many requests ask for IDs that don't exist, cache the "not found" briefly too, or every one of them hits the database.
  • Stampede on a hot key: let one request rebuild the value while others wait, or refresh popular keys shortly before they expire.

Follow-ups

  • What TTL would you choose? It depends on how stale the data may be: seconds for stock levels, minutes for product details, hours for categories.
  • Where else could this cache live? For public product pages, a CDN in front with Cache-Control headers (question 32) can serve most traffic without touching the server at all.

08Architecture basics

0/3 got it

Q35Not marked yetFresherAsked very oftenConcept

Monolith vs microservices: what are the trade-offs, and which would you start a new product with?

Say this first~30 sec to say

A monolith is one codebase deployed as one unit. Microservices split the system into small services, each owning its data and deployed independently, talking over the network. Microservices let teams ship and scale parts separately, but bring network failures, distributed data, harder debugging and far more operational work. For a new product with a small team, start with a well-structured, modular monolith, and split out a service only when a specific part needs independent scaling or its own team.

Could you say it?
Full answer2 min read
MonolithMicroservices
DeploymentOne unitEach service separately
Calls between partsIn-process function calls: fast, reliableNetwork calls: slower, can fail, need retries and timeouts
DataOne database, easy transactionsA database per service; consistency across services is hard
ScalingScale the whole appScale only the busy service
DebuggingOne log, one stack traceDistributed tracing across services
Team fitOne or a few teamsMany teams, each owning services
OperationsSimpleService discovery, per-service CI/CD, monitoring, versioned APIs
FailureA bug can take everything downFailures can be isolated, if designed well

Why "monolith first" is the usual advice

  • Early on, you don't yet know where the right boundaries are. Splitting in the wrong places is expensive to undo.
  • A small team spends its time on the product, not on networking, deployment and observability for ten services.
  • A modular monolith, with clear internal modules (orders, payments, catalogue) that talk through defined interfaces, keeps the option to extract services later.

Signs it's time to extract a service: one part needs very different scaling (an image-processing pipeline), a separate team needs to deploy on its own schedule, or a part needs different technology or stricter isolation (payments).

Follow-ups

  • How do microservices talk? Synchronously over HTTP/REST or gRPC, or asynchronously through events on a message queue (question 37).
  • What is an API gateway? A single entry point in front of the services that handles routing, authentication and rate limiting.
Q36Not marked yetFresherAsked very oftenConcept

Vertical vs horizontal scaling: how do they differ, and what does a load balancer do once you scale out?

Say this first~30 sec to say

Vertical scaling (scaling up) moves to a bigger machine with more CPU and RAM: simple, with no code changes, but it has a ceiling, gets expensive, and one machine is still a single point of failure. Horizontal scaling (scaling out) adds more machines running the same app: nearly unlimited and fault-tolerant, but the app must be stateless, keeping sessions and files in shared stores. A load balancer then spreads requests across the healthy servers, runs health checks, and often handles TLS.

Could you say it?
Full answer2 min read
                    ┌──────────► app server 1 ──┐
users ──► load ─────┼──────────► app server 2 ──┼──► database, Redis (sessions, cache),
          balancer  └──────────► app server 3 ──┘    object storage (uploads)
          (health checks, TLS)
VerticalHorizontal
HowA bigger serverMore servers
LimitThe biggest machine you can buyEffectively none
Downtime to scaleOften a restartNone: add servers behind the balancer
Single point of failureYesNo, with at least two servers
Code changesNoneThe app must be stateless
Cost curveRises steeply at the top endRoughly linear

Making an app stateless: sessions go in Redis or a database (or become JWTs), uploaded files go to object storage such as S3, and nothing a later request depends on lives in one server's memory or disk.

How load balancers choose a server

  • Round robin: each server in turn.
  • Least connections: the server with the fewest active requests, good when requests vary in length.
  • IP hash, or sticky sessions: the same client always reaches the same server. It helps with in-memory state, but undermines even spreading and failover.

Follow-ups

  • What about the database? It's usually the hardest part to scale: read replicas for reads, caching, connection pooling, and eventually sharding.
  • What is autoscaling? Adding and removing servers automatically based on load, such as CPU use or request count.
Q37Not marked yet1–3 yrsAsked sometimesConcept

What is a message queue, and when would you use one instead of calling another service directly?

Say this first~30 sec to say

A message queue lets one part of a system hand work to another asynchronously: a producer adds a message (to RabbitMQ, SQS, Kafka or BullMQ) and moves on, and consumers process messages at their own pace. Use one when the work is slow and the user needn't wait (emails, invoices), when traffic comes in bursts, when the consumer may be down, or when several services react to one event.

Could you say it?
Full answer3 min read

The idea in a few lines

A real queue is a separate, durable service; this in-process version just shows the moving parts: a producer that doesn't wait, a consumer working at its own pace, and retries:

JavaScript
class TinyQueue {
  constructor(handler, { maxAttempts = 3 } = {}) {
    this.jobs = [];
    this.handler = handler;
    this.maxAttempts = maxAttempts;
    this.deadLetters = [];
  }
  add(data) {
    this.jobs.push({ data, attempts: 0 });         // the producer returns immediately
  }
  async work() {
    while (this.jobs.length) {
      const job = this.jobs.shift();
      try {
        job.attempts += 1;
        await this.handler(job.data);
      } catch (err) {
        if (job.attempts < this.maxAttempts) this.jobs.push(job);   // retry later
        else this.deadLetters.push({ ...job, error: err.message }); // give up, keep it for inspection
      }
    }
  }
}

let flaky = 0;
const emails = new TinyQueue(async ({ to }) => {
  if (to === "[email protected]" && flaky++ < 1) throw new Error("SMTP timeout");   // fails once
  if (to === "bad-address") throw new Error("invalid recipient");                  // always fails
  console.log(`sent to ${to}`);
});

emails.add({ to: "[email protected]" });           // e.g. from POST /orders, which responds at once
emails.add({ to: "[email protected]" });
emails.add({ to: "bad-address" });
await emails.work();
console.log("dead letters:", emails.deadLetters.map((j) => j.data.to));
sent to [email protected]
sent to [email protected]
dead letters: [ 'bad-address' ]
Direct call (HTTP)Through a queue
The caller waits for the resultThe caller continues immediately
If the callee is down, the call failsMessages wait until the consumer is back
A traffic spike hits the callee directlyThe queue absorbs it; consumers drain it steadily
One caller, one calleeOne event, many consumers (with pub/sub or topics)
Simple to follow and debugMore moving parts; eventual consistency

What production queues add: durable storage, acknowledgements (a message is removed only after it's processed), retries with backoff, dead-letter queues, and ordering guarantees per key or partition.

Follow-ups

  • What does "at least once" delivery mean for your code? A message can arrive twice, after a crash between processing and acknowledging, so consumers must be idempotent (question 8).
  • Queue or event stream? A queue (SQS, RabbitMQ) hands each message to one consumer and deletes it. A stream (Kafka) keeps an ordered log that many consumer groups can read and replay.

09Deployment and operations

0/3 got it

Q38Not marked yetFresherAsked oftenConcept

What are environment variables used for, and why shouldn't secrets be committed to the repository?

Say this first~25 sec to say

Environment variables hold configuration that differs between environments, such as the database URL, API keys and port, outside the code, so one build runs everywhere. Never commit secrets: Git history is permanent, and bots find leaked keys within minutes. Keep local values in a git-ignored .env, commit a .env.example with dummy values, and use the platform's secret settings or a secrets manager in production.

Could you say it?
Full answer2 min read
# .env: real values, never committed
DATABASE_URL=postgres://app:local-password@localhost:5432/shop
JWT_SECRET=a-long-random-string-for-local-development
PORT=3000
# .gitignore
.env
.env.*
!.env.example

Reading and checking config at startup

JavaScript
// config.mjs: run with `node --env-file=.env config.mjs` (Node 20.6 and later)
const required = ["DATABASE_URL", "JWT_SECRET"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) {
  console.error(`Missing environment variables: ${missing.join(", ")}`);
  process.exit(1);                               // fail fast, at startup, not on the first request
}

export const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  port: Number(process.env.PORT ?? 3000),        // everything arrives as a string
};
console.log(`config loaded, port ${config.port}`);

Recent Node versions read .env files themselves, with --env-file, so the dotenv package is no longer required for this.

If a secret is committed: treat it as leaked. Rotate it (issue a new key and revoke the old one) first; cleaning it out of Git history afterwards is good hygiene, but copies may already exist.

Where secrets live in production: the hosting platform's encrypted settings (Vercel, Railway, Render, Heroku), Kubernetes Secrets, or a secrets manager such as AWS Secrets Manager or HashiCorp Vault, with access limited and logged.

Follow-ups

  • Is a private repository safe for secrets? Safer, not safe: anyone with access, every clone, CI logs and future leaks all expose them.
  • Should front-end code see these variables? Only public ones. Anything bundled into browser JavaScript is visible to everyone, which is why frameworks require prefixes such as NEXT_PUBLIC_ for client-side variables.
Q39Not marked yetFresherAsked oftenConcept

What is Docker, and how is a container different from a virtual machine?

Say this first~30 sec to say

Docker packages an app with its runtime, libraries and configuration into an image, built from a Dockerfile; a container is a running instance of it. Containers share the host's kernel, so they start in seconds and use little memory. A virtual machine runs a full guest operating system: stronger isolation, but gigabytes and minutes to boot. Every environment runs the same image, so "works on my machine" goes away.

Could you say it?
Full answer2 min read
Dockerfile
# Dockerfile for a Node.js API
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev          # install production dependencies only (this layer is cached)
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node                      # don't run as root
CMD ["node", "server.js"]
Terminal
docker build -t shop-api:1.0 .                                   # build the image
docker run -d -p 3000:3000 --env-file .env shop-api:1.0           # run a container from it
docker ps                                                         # list running containers
docker logs <container-id>                                        # see its output
ContainerVirtual machine
VirtualisesThe operating system: processes, files, networkThe hardware
IncludesThe app and its dependenciesA whole guest operating system
KernelShared with the hostIts own
SizeMegabytesGigabytes
Starts inSeconds or lessMinutes
IsolationProcess-level: goodHardware-level: stronger
Typical usePackaging and deploying apps, microservices, CIRunning different operating systems, strong multi-tenant isolation

Image vs container: an image is like a class, a container like an object made from it. You can run many containers from one image.

Follow-ups

  • Why copy package.json before the rest of the code? Docker caches each layer. Dependencies change less often than code, so installing them in an earlier layer makes rebuilds much faster.
  • What is Docker Compose? A file that describes several containers together, such as the API, PostgreSQL and Redis, so docker compose up starts the whole local stack.
  • And Kubernetes? It runs and manages containers across many machines: scheduling, scaling, restarts and rolling deployments.
Q40Not marked yetFresherAsked oftenConcept

What is CI/CD, and what would a basic pipeline for a backend API do?

Say this first~30 sec to say

Continuous integration means every push is automatically built and tested, so problems surface within minutes, while the change is still small. Continuous delivery keeps the main branch always ready to deploy, with releases one approval away; continuous deployment goes further and ships every passing change to production automatically. A basic pipeline for an API: install dependencies, lint, run unit and integration tests against a test database, build a Docker image, deploy to staging, run smoke tests, then deploy to production.

Could you say it?
Full answer3 min read
YAML
# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/postgres

  deploy:
    needs: test                          # only runs if the tests passed
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t shop-api:${{ github.sha }} .
      - run: echo "push the image and deploy to staging here"
PracticeWhat happens automaticallyWho decides to release
Continuous integrationBuild and test every change
Continuous deliveryCI, plus a deployable artifact for every changeA person clicks deploy
Continuous deploymentCI, plus automatic release of every passing changeNobody: the pipeline does

What a good pipeline guards against

  • Broken builds on main: pull requests must pass the same checks before merging.
  • "Works locally" bugs: tests run in a clean environment every time.
  • Risky releases: staging first, smoke tests, then gradual or blue-green rollouts with a quick rollback.
  • Leaked secrets: credentials come from the CI system's secret store, never from the repository (question 38).

Follow-ups

  • What are smoke tests? A handful of quick checks after deployment, such as "does /health return 200?" and "can a test user log in?", to catch a broken release immediately.
  • What is a rollback strategy? Keep the previous image ready, and switch traffic back if errors spike after a deploy. Blue-green deployments keep both versions running to make that instant.

Last step

Write these from memory

Before a backend interview, make sure you can write each of these on a blank page without help:

  • Q10
  • Q11
  • Q14
  • Q15
  • Q18
  • Q19
  • Q23
  • Q25
  • Q26
  • Q27
  • Q34

0 of 11 done