CS Prep Hub

Full Stack Development — MERN

A zero-to-hero interview reference for the MERN stack (MongoDB, Express, React, Node.js) — covering frontend fundamentals, the React mental model, Node's runtime internals, REST/HTTP semantics, authentication, security, performance, testing, deployment, and web system design. Every section includes runnable code, comparison tables, and interview Q&A.

1. Frontend Fundamentals

Semantic HTML & Accessibility Basic

Semantic HTML means using tags that describe the meaning of content, not just its appearance — <header>, <nav>, <main>, <article>, <section>, <aside>, <footer>, <button> instead of a pile of <div>s. This matters for three reasons: screen readers and assistive tech rely on the semantic tree to navigate a page, search engines use it for indexing/SEO, and it gives you free keyboard behavior (a real <button> is focusable and triggers on Enter/Space with zero JS).

Accessibility (a11y) basics an interviewer expects: sufficient color contrast, all interactive elements reachable and operable via keyboard (tabindex, visible focus states), alt text on meaningful images (empty alt="" for decorative ones), form <label>s tied to inputs via for/id, and ARIA attributes (aria-label, aria-hidden, role) used only when native semantics can't express the intent — "no ARIA is better than bad ARIA."

💡 Interview Tip

If asked "why not just use divs with onClick everywhere," the strongest answer combines three angles: accessibility (keyboard/screen-reader support), semantics (SEO + maintainability), and free browser behavior (focus management, form submission on Enter).

Q: What's the difference between <section> and <div>?

<section> represents a thematic grouping of content that typically has its own heading and is meaningful in the document outline (e.g. picked up by accessibility trees / outline algorithms). <div> carries no semantic meaning — it's purely a styling/scripting hook. Use <section> when the group of content would make sense as its own "chapter"; use <div> otherwise.

Q: How would you make a custom dropdown component accessible?

Give the trigger role="button" (or use a real <button>), manage aria-expanded, wrap the options list with role="listbox"/role="option", trap and move focus with arrow keys, close on Escape, and return focus to the trigger on close. Native <select> gets all of this for free, which is why it's often preferred unless custom styling is a hard requirement.

CSS Box Model, Specificity & Flexbox vs Grid Basic

Every element is a rectangular box made of four layers, from inside out: contentpaddingbordermargin. By default (box-sizing: content-box), width/height apply only to the content box, so padding and border add on top of the stated size. Setting box-sizing: border-box (the near-universal reset) makes width/height include padding and border, which is far more predictable for layout.

CSS
*, *::before, *::after {
  box-sizing: border-box;
}

Specificity decides which CSS rule wins when multiple rules target the same element. It's computed as a tuple (inline styles, IDs, classes/attributes/pseudo-classes, element/pseudo-elements) — roughly: inline style (1000) > ID selector (100 each) > class/attribute/pseudo-class (10 each) > element/pseudo-element (1 each). Equal specificity falls back to source order (last rule wins); !important overrides normal specificity entirely (and should be used sparingly).

Flexbox vs Grid — When to Use Which

Flexbox is one-dimensional — it lays items out along a single axis (row or column) and excels at distributing space between items, centering, and building components like navbars, toolbars, and card rows where content size should drive layout. Grid is two-dimensional — it lets you define both rows and columns explicitly and excels at whole-page layouts, dashboards, and anywhere you need precise placement across both axes.

Use caseFlexboxGrid
Navbar / toolbar✅ IdealOverkill
Card grid with fixed rows/colsPossible but clunky✅ Ideal
Centering one item✅ Simple✅ Simple
Full page layout (header/sidebar/main/footer)Awkward✅ Ideal
Content-driven sizing (items wrap naturally)✅ IdealPossible
Q: Can Flexbox and Grid be nested inside each other?

Yes, and it's common in practice — e.g. Grid for the overall page skeleton, Flexbox inside each grid cell to align its internal content. They compose fine because each only controls its own children's layout.

The JavaScript Event Loop Intermediate

JavaScript is single-threaded — one call stack. The event loop is what lets it appear concurrent: it continuously checks whether the call stack is empty, and if so, pulls the next task from a queue and pushes it onto the stack. There are two categories of queue with different priority:

  • Macrotask (task) queuesetTimeout, setInterval, I/O callbacks, UI rendering, setImmediate (Node).
  • Microtask queuePromise.then/catch/finally callbacks, queueMicrotask, MutationObserver.

The rule: after each macrotask finishes, the event loop drains the entire microtask queue (including any new microtasks queued during draining) before running the next macrotask or repainting. Microtasks always run before the next macrotask, no matter which was scheduled first.

JavaScript
console.log("1: sync start");

setTimeout(() => console.log("2: setTimeout (macrotask)"), 0);

Promise.resolve().then(() => console.log("3: promise.then (microtask)"));

console.log("4: sync end");

// Output order:
// 1: sync start
// 4: sync end
// 3: promise.then (microtask)
// 2: setTimeout (macrotask)

Why? All synchronous code runs first (it's already on the stack). Once the stack is empty, the microtask queue is drained fully — so the Promise.then callback runs even though setTimeout(..., 0) was scheduled earlier. Only after the microtask queue is empty does the event loop move to the next macrotask.

⚠️ Common Pitfall

A microtask that keeps queuing more microtasks (e.g. a promise chain that recursively schedules itself) can starve the macrotask queue indefinitely, freezing timers and UI updates — this is a real, subtle production bug class.

Q: Where does rendering fit into the event loop, in a browser?

Browsers typically render after the microtask queue is drained and before the next macrotask, roughly once per frame (~16.6ms budget at 60fps) — this is why long synchronous work or unbounded microtask chains cause jank: the browser can't get a chance to paint.

Q: Is async/await just sugar over promises, event-loop-wise?

Yes. Code after an await is scheduled as a microtask continuation — functionally equivalent to chaining .then(). An async function returns a promise immediately upon hitting the first await, releasing the call stack, and resumes later as a microtask when the awaited value resolves.

Closures

A closure is a function bundled with references to its surrounding (lexical) scope — it "remembers" variables from the scope it was defined in, even after that outer function has returned. Closures are the mechanism behind private state, memoization, and factory functions in JS.

JavaScript
function makeCounter() {
  let count = 0; // enclosed, private to each counter instance
  return {
    increment: () => ++count,
    reset: () => { count = 0; },
  };
}

const counterA = makeCounter();
const counterB = makeCounter();
counterA.increment();
counterA.increment();
console.log(counterA.increment()); // 3
console.log(counterB.increment()); // 1 — independent closure, own `count`
Q: Classic pitfall — why does a var loop print "3, 3, 3" with a setTimeout, and how do you fix it?

var is function-scoped, so all three callbacks close over the same single i, whose final value (3) they all see once the loop finishes. Fixes: use let (block-scoped — a fresh binding per iteration), or wrap in an IIFE that captures the current value as a parameter.

Prototypal Inheritance & this Binding Intermediate

Every JS object has an internal link ([[Prototype]], accessible via Object.getPrototypeOf or the legacy __proto__) to another object. Property lookups walk this prototype chain until found or until it reaches null. class syntax is sugar over this same mechanism — a class's methods live on ClassName.prototype, shared by every instance rather than copied per instance.

JavaScript
const animal = { speak() { return `${this.name} makes a sound`; } };
const dog = Object.create(animal); // dog's prototype is animal
dog.name = "Rex";
console.log(dog.speak()); // "Rex makes a sound" — found via the prototype chain

`this` Binding Rules

this is determined by how a function is called, not where it's defined (except arrow functions):

  1. Default binding — plain function call: this is undefined in strict mode (or the global object otherwise).
  2. Implicit binding — called as obj.method(): this is obj.
  3. Explicit bindingfn.call(obj), fn.apply(obj), fn.bind(obj): you set this directly.
  4. new bindingnew Fn(): this is the freshly created object.
  5. Arrow functions — no own this; they lexically inherit this from their enclosing scope at definition time, and it can never be reassigned via call/apply/bind.
⚠️ Common Pitfall

Passing an object method as a bare callback (e.g. setTimeout(obj.method, 100)) loses its implicit binding — this inside method will no longer be obj. Fix with .bind(obj) or an arrow-function wrapper: setTimeout(() => obj.method(), 100).

ES6+ Features

JavaScript
// Destructuring
const { name, age = 18 } = user;
const [first, ...rest] = [1, 2, 3, 4]; // rest = [2, 3, 4]

// Spread
const merged = { ...defaults, ...overrides };
const combined = [...arrA, ...arrB];

// Arrow functions + lexical `this`
class Timer {
  constructor() { this.seconds = 0; }
  start() {
    // arrow fn inherits `this` from `start`, so it correctly refers to the instance
    setInterval(() => { this.seconds++; }, 1000);
  }
}

// Modules (ESM)
export function add(a, b) { return a + b; }
import { add } from "./math.js";

// Optional chaining + nullish coalescing
const city = user?.address?.city ?? "Unknown";

Promises & async/await

A Promise represents an eventual value with three states: pending, fulfilled, rejected — once settled, it's immutable. async/await is syntax that lets promise-based code read like synchronous code, with try/catch for error handling instead of .catch() chains.

JavaScript
async function getUserOrders(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    return orders;
  } catch (err) {
    console.error("Failed to load orders:", err.message);
    throw err;
  }
}

// Run independent requests concurrently, not sequentially
async function getDashboard(userId) {
  const [user, orders, notifications] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchNotifications(userId),
  ]);
  return { user, orders, notifications };
}
Q: Promise.all vs Promise.allSettled vs Promise.race vs Promise.any?

Promise.all resolves when all resolve, but rejects immediately on the first rejection. Promise.allSettled always resolves, giving you the status of every promise (fulfilled or rejected) — useful when you want all results regardless of individual failures. Promise.race settles as soon as the first promise settles (fulfilled or rejected) — useful for timeouts. Promise.any resolves as soon as the first one fulfills, and only rejects if all reject.

Debouncing & Throttling

Debounce delays execution until a burst of calls goes quiet for a given interval — ideal for search-as-you-type inputs where you only want to fire the API call after the user stops typing. Throttle guarantees execution at most once per interval regardless of how many times the function is called — ideal for scroll/resize handlers where you want steady, bounded-rate updates.

JavaScript
function debounce(fn, delay) {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
}

function throttle(fn, interval) {
  let lastCall = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastCall >= interval) {
      lastCall = now;
      fn(...args);
    }
  };
}

const searchDebounced = debounce((q) => fetchResults(q), 300);
const onScrollThrottled = throttle(() => updateScrollUI(), 200);

2. React

Functional vs Class Components Basic

Class components use ES6 classes, this.state, this.setState(), and lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount). Functional components are plain functions that use Hooks (useState, useEffect, etc.) to get state and lifecycle behavior. Since React 16.8, functional components + hooks are the standard — they're more concise, avoid this-binding footguns, and make logic reuse easier via custom hooks instead of higher-order components or render props.

JSX Under the Hood

JSX isn't understood by browsers — Babel (or the TS compiler) transforms it into plain React.createElement calls (or, with the modern JSX runtime, calls to jsx/jsxs from react/jsx-runtime), which produce plain JS objects describing the UI tree ("React elements").

JSX
// You write:
const element = <h1 className="title">Hello, {name}</h1>;

// Babel compiles it to (classic runtime):
const element = React.createElement(
  "h1",
  { className: "title" },
  "Hello, ",
  name
);

// Which produces an object roughly like:
// {
//   type: "h1",
//   props: { className: "title", children: ["Hello, ", name] }
// }

React then takes this tree of plain objects and reconciles it against the previous tree to compute the minimal set of real DOM mutations.

Props vs State

Props are read-only inputs passed from a parent to a child — the child cannot mutate them (unidirectional data flow). State is data owned and managed internally by a component, which can change over time and triggers a re-render when updated via its setter. A common interview framing: props flow down, events flow up (a child notifies a parent of changes by calling a callback prop, not by mutating props directly).

Hooks Overview Intermediate

HookPurposeCommon gotcha
useStateLocal component stateUpdates are async/batched; the setter doesn't merge objects like class setState did — spread manually.
useEffectSide effects (fetch, subscriptions, DOM APIs) after renderMissing dependency-array entries cause stale closures; forgetting cleanup causes leaks/duplicate subscriptions.
useMemoMemoize an expensive computed valueOverusing it adds overhead for cheap computations — profile first.
useCallbackMemoize a function reference (stable identity across renders)Pointless without a memoized child (React.memo) or a dependency array elsewhere relying on referential equality.
useRefMutable value that persists across renders without causing re-renders; DOM node accessMutating .current doesn't trigger re-render — don't use it for values that should drive UI.
useContextRead a value from the nearest Context.Provider aboveAny context value change re-renders all consumers — split contexts to avoid over-rendering.
JSX
function ProfileCard({ userId }) {
  const [user, setUser] = useState(null);
  const renderCount = useRef(0);
  renderCount.current++;

  const fullName = useMemo(
    () => user ? `${user.first} ${user.last}` : "",
    [user]
  );

  const handleRefresh = useCallback(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  useEffect(() => {
    let cancelled = false;
    fetchUser(userId).then((data) => {
      if (!cancelled) setUser(data);
    });
    return () => { cancelled = true; }; // cleanup avoids setting state on unmounted component
  }, [userId]);

  return (
    <div>
      <h3>{fullName}</h3>
      <button onClick={handleRefresh}>Refresh</button>
    </div>
  );
}

useEffect Deep Dive: Dependency Array & Cleanup Advanced

useEffect(fn, deps) runs fn after the browser paints, and re-runs it whenever any value in deps changes (by Object.is comparison — reference equality for objects/arrays/functions). Three dependency-array behaviors to know cold:

  • No array — runs after every render.
  • Empty array [] — runs once, after the initial mount only.
  • Array with values — runs on mount and whenever any listed value changes.

If the effect returns a function, React calls it as cleanup before the effect re-runs and when the component unmounts — essential for cancelling subscriptions, clearing timers, and aborting in-flight fetches to avoid the classic "setting state on an unmounted component" warning and race conditions.

⚠️ Common Pitfall

Omitting a variable used inside the effect from the dependency array creates a stale closure — the effect keeps referencing the value from whichever render it was created in, not the latest one. ESLint's react-hooks/exhaustive-deps rule catches most of these; disabling it without understanding why is a red flag interviewers watch for.

Q: Why does an empty dependency array sometimes cause a bug with a value that changes over time (e.g. reading a prop inside a setInterval)?

The effect closure captures the prop's value from the render it was created in. Since the effect never re-runs (empty deps), the interval callback keeps using that first, now-stale, value forever. Fix: add the prop to the dependency array (so the interval is torn down and rebuilt with the fresh value), or use a ref to always read the latest value without re-subscribing.

Custom Hooks — Worked Example: useFetch

A custom hook is just a function whose name starts with use and that calls other hooks internally — it lets you extract and reuse stateful logic across components without changing the component tree (unlike render props / HOCs).

JSX
function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setData)
      .catch((err) => {
        if (err.name !== "AbortError") setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort(); // cancel stale request on url change/unmount
  }, [url]);

  return { data, error, loading };
}

// Usage
function UserProfile({ id }) {
  const { data: user, loading, error } = useFetch(`/api/users/${id}`);
  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <h2>{user.name}</h2>;
}

Reconciliation & Virtual DOM Diffing Advanced

React keeps a lightweight in-memory representation of the UI (the virtual DOM). On every state/prop change, it builds a new tree and diffs it against the previous one to compute the minimal set of real DOM operations — this is reconciliation. To make an O(n³) generic tree-diff tractable, React uses heuristics:

  • Different element types at the same position → tear down the old subtree entirely and build a new one (no attempt to diff children).
  • Same element type → keep the DOM node, update only changed attributes, and recurse into children.
  • Lists — React matches children across renders using the key prop rather than position, so it can detect insertions/removals/reorders instead of re-rendering everything from the change point onward.
⚠️ Common Pitfall — Array Index as Key

Using the array index as key works fine for a static list but breaks badly once items are inserted, removed, or reordered: React matches by position, not identity, so it may reuse the wrong DOM node/state for the wrong logical item (e.g. text inputs showing the wrong value after a reorder, or components not remounting when they should). Always use a stable, unique identifier from your data (e.g. a database _id) as the key.

Controlled vs Uncontrolled Components

A controlled component has its value driven entirely by React state — the input's value comes from state and every change goes through onChangesetState. An uncontrolled component lets the DOM manage its own state internally, and you read it on demand via a ref (defaultValue instead of value). Controlled is preferred for validation, conditional enabling/disabling, and formatting-as-you-type; uncontrolled is simpler and slightly cheaper for large forms with plain, one-shot submission (e.g. file inputs, which are inherently uncontrolled).

JSX
// Controlled
function ControlledInput() {
  const [value, setValue] = useState("");
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

// Uncontrolled
function UncontrolledInput() {
  const inputRef = useRef(null);
  const handleSubmit = () => console.log(inputRef.current.value);
  return <input ref={inputRef} defaultValue="" />;
}

React Router Basics

React Router provides client-side routing so navigation feels instant (no full page reload): it swaps rendered components based on the URL using the History API. Core pieces: <BrowserRouter> (wraps the app), <Routes>/<Route path="..." element={...}> (declare paths), <Link>/<NavLink> (navigate without reload), useParams (read dynamic segments like /users/:id), useNavigate (programmatic navigation), and nested routes with <Outlet /> for shared layouts.

JSX
<BrowserRouter>
  <Routes>
    <Route path="/" element={<Layout />}>
      <Route index element={<Home />} />
      <Route path="users/:id" element={<UserProfile />} />
      <Route path="*" element={<NotFound />} />
    </Route>
  </Routes>
</BrowserRouter>

function UserProfile() {
  const { id } = useParams();
  const navigate = useNavigate();
  return <button onClick={() => navigate(-1)}>Back (user {id})</button>;
}

State Management: Context API vs Redux

Context API (built into React) solves "prop drilling" by letting any descendant read a value without it being threaded through every intermediate component. It's great for low-frequency, broadly-needed data (theme, current user, locale). Redux (or Zustand/Jotai/Recoil) adds a predictable, centralized store with explicit actions/reducers, time-travel debugging, middleware (for async, logging), and — critically — fine-grained subscriptions so components only re-render when the specific slice of state they read changes.

💡 Interview Tip

"When do you actually need Redux?" — a strong answer: when state is complex, updated frequently from many places, needs to be shared across distant/unrelated components, or you need tooling like time-travel debugging/middleware. Plain Context re-renders every consumer on any value change, which becomes a real performance problem for high-frequency state — Context is best for rarely-changing, broadly-read values, not a general state-management replacement for Redux.

Performance Optimization

React.memo(Component) skips re-rendering a component if its props are shallow-equal to the previous render — pairs with useCallback/useMemo so that function/object props passed down don't get a new reference every render (which would defeat the memoization). React.lazy(() => import("./Chart")) combined with <Suspense> code-splits a component into its own bundle chunk, loaded only when first rendered — shrinks the initial bundle and speeds up first paint.

JSX
const Chart = React.lazy(() => import("./Chart"));

function Dashboard() {
  return (
    <Suspense fallback={<p>Loading chart…</p>}>
      <Chart />
    </Suspense>
  );
}

const ExpensiveRow = React.memo(function ExpensiveRow({ item, onSelect }) {
  console.log("rendering", item.id);
  return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});
Q: What causes "unnecessary" re-renders and how do you avoid them?

Most commonly: a parent re-renders and passes brand-new object/array/function literals as props (new reference every time, defeating React.memo's shallow comparison), overly broad Context values causing every consumer to re-render on unrelated changes, and state living higher in the tree than it needs to (so a wide swath re-renders for a change that only affects a small part). Fixes: useMemo/useCallback for stable references, splitting Context by concern, and pushing state down to the smallest component that needs it.

Q: Does useMemo guarantee the value is never recomputed?

No — it's a performance hint, not a semantic guarantee. React may discard the cached value and recompute in certain situations (e.g. it's explicitly documented as not something to rely on for correctness, only for optimization). Never rely on useMemo for side effects or correctness-critical memoization.

3. Node.js

Node's Event Loop Phases Advanced

Node's event loop (implemented by libuv) runs in a fixed sequence of phases each iteration ("tick"), each with its own FIFO callback queue:

  1. timers — executes callbacks scheduled by setTimeout/setInterval whose threshold has elapsed.
  2. pending callbacks — executes I/O callbacks deferred from the previous loop iteration (some system-level errors).
  3. poll — retrieves new I/O events and executes their callbacks (this is where most work happens); it will block here waiting for I/O if nothing else is scheduled.
  4. check — executes setImmediate callbacks, which run right after the poll phase.
  5. close callbacks — e.g. socket.on('close', ...).

Between every phase transition (and after every callback), Node drains the microtask queue (process.nextTick queue first, fully, then Promise microtasks) — same idea as the browser, applied around each phase rather than only around a single "task."

Q: setTimeout(fn, 0) vs setImmediate(fn) — which runs first?

It's technically unspecified at the top level of the main module (depends on process startup timing), but inside an I/O callback, setImmediate is always guaranteed to run before any setTimeout, because the poll phase transitions directly into the check phase (where setImmediate lives) before looping back to timers.

Q: What's special about process.nextTick?

It's not part of the libuv phase cycle at all — its queue is drained completely after the current operation finishes, before the event loop proceeds to the next phase, and even before Promise microtasks. It has the highest priority in Node, which also means abusing it recursively can starve the entire event loop.

libuv & the Thread Pool

Node's JS execution is single-threaded, but libuv maintains a small worker thread pool (default size 4, configurable via UV_THREADPOOL_SIZE) to offload blocking operations that don't have a native async OS API — mainly filesystem operations (fs.readFile, etc.), DNS lookups (dns.lookup), and some crypto functions (crypto.pbkdf2, bcrypt). Network I/O, by contrast, uses the OS's native async mechanisms (epoll/kqueue/IOCP) directly and doesn't need the thread pool at all.

CommonJS vs ESM

CommonJSESM (ECMAScript Modules)
Syntaxrequire() / module.exportsimport / export
LoadingSynchronous, resolved at runtimeStatic, resolved/analyzed at parse time (enables tree-shaking)
Binding typeCopies the exported valueLive bindings — importers see updates
File extension.js (default) / .cjs.mjs, or .js with "type": "module"
this at module top levelmodule.exportsundefined

Streams

Streams process data piece-by-piece (chunks) instead of loading an entire resource into memory at once — essential for large files, video, or big HTTP responses. Node has four stream types: Readable (source, e.g. fs.createReadStream), Writable (sink, e.g. fs.createWriteStream, HTTP response), Duplex (both, e.g. a TCP socket), and Transform (duplex that modifies data as it passes through, e.g. gzip).

JavaScript
const fs = require("fs");
const zlib = require("zlib");

// Streaming: constant, low memory usage regardless of file size
fs.createReadStream("large-video.mp4")
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("large-video.mp4.gz"));

// vs. loading the whole file into memory first (bad for large files):
// const data = fs.readFileSync("large-video.mp4"); // blocks + high memory

Buffers

A Buffer is Node's way of handling raw binary data outside V8's string-only, UTF-16-based memory model — a fixed-size chunk of memory allocated outside the JS heap. Streams emit chunks as Buffer instances by default; you convert to a string with an explicit encoding (buf.toString("utf8")) when needed.

Single-Threaded Model & Concurrency

Node runs your JS on one thread, but achieves high concurrency for I/O-bound workloads through non-blocking, event-driven I/O: instead of blocking the thread waiting for a database query or file read to finish, it registers a callback and moves on, letting the OS/libuv notify it when the operation completes. This makes Node excellent for I/O-heavy workloads (APIs, real-time apps) but poor for CPU-heavy synchronous work (image processing, heavy computation), which blocks the single thread and stalls every other request until it's done.

⚠️ Warning

A single slow synchronous function (e.g. a large JSON.parse, a tight computational loop, or a synchronous crypto call) blocks the entire event loop — every other request, timer, and I/O callback waits. This is the single most common cause of "why is my Node API suddenly unresponsive under load."

Clustering & Worker Threads

Both let Node use multiple CPU cores, but for different purposes. The cluster module forks multiple full Node processes (each with its own event loop and memory), and a built-in load balancer distributes incoming connections across them — ideal for scaling I/O-bound HTTP servers across cores. Worker threads run true threads within the same process, sharing memory via SharedArrayBuffer if needed, and are meant for offloading CPU-bound work (image resizing, heavy computation) without blocking the main event loop — lighter weight than spawning whole processes but able to run genuinely parallel JS.

JavaScript
// cluster.js — scale an HTTP server across CPU cores
const cluster = require("cluster");
const os = require("os");
const http = require("http");

if (cluster.isPrimary) {
  os.cpus().forEach(() => cluster.fork());
  cluster.on("exit", (worker) => {
    console.log(`Worker ${worker.process.pid} died, forking a replacement`);
    cluster.fork();
  });
} else {
  http.createServer((req, res) => res.end("handled by " + process.pid)).listen(3000);
}

4. Express.js

Middleware Concept Basic

Express processes every request through a chain of middleware functions: (req, res, next). Each middleware can inspect/modify req/res, end the request-response cycle, or call next() to pass control to the next middleware in the chain. If next() is never called, the request hangs forever (a very common bug).

JavaScript
const express = require("express");
const app = express();

app.use(express.json()); // parse JSON bodies — runs on every request

function logger(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next(); // must call next() or the request stalls
}
app.use(logger);

function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: "Missing token" }); // ends the cycle here
  }
  next();
}

app.get("/api/profile", requireAuth, (req, res) => {
  res.json({ name: "Ada" });
});

Routing

Routes map an HTTP verb + path pattern to a handler. Express supports route parameters (:id), query strings (req.query), and modular routers (express.Router()) to split routes across files for larger apps.

JavaScript
// routes/users.js
const router = require("express").Router();

router.get("/", listUsers);
router.get("/:id", getUser);
router.post("/", createUser);
router.put("/:id", updateUser);
router.delete("/:id", deleteUser);

module.exports = router;

// app.js
app.use("/api/users", require("./routes/users"));

Error-Handling Middleware

Error handlers are middleware with four parameters — (err, req, res, next) — Express recognizes this signature specifically and routes errors to it. Synchronous throws are caught automatically; async errors must be forwarded explicitly via next(err) (or use a wrapper/Express 5's built-in promise rejection handling).

JavaScript
// Central error handler — must be registered last, after all routes
app.use((err, req, res, next) => {
  console.error(err.stack);
  const status = err.status || 500;
  res.status(status).json({ error: err.message || "Internal Server Error" });
});

// Wrapper to forward async errors without try/catch boilerplate in every route
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

app.get("/api/users/:id", asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    const err = new Error("User not found");
    err.status = 404;
    throw err;
  }
  res.json(user);
}));

REST API Design Principles

  • Resource naming — use plural nouns for collections (/users, not /getUsers), nested resources for relationships (/users/:id/orders), and avoid verbs in URLs (the HTTP verb already expresses the action).
  • Proper verb usageGET to read, POST to create, PUT/PATCH to update (full vs partial), DELETE to remove.
  • Statelessness — each request must carry all information needed to process it (e.g. an auth token); the server holds no client session context between requests, which is what makes REST APIs horizontally scalable behind a load balancer with no sticky sessions required.

Request Lifecycle in Express

1) Request hits the server → 2) global middleware runs in registration order (body parsers, CORS, logging, auth) → 3) the matching route's own middleware chain runs → 4) the route handler sends a response (res.json/res.send/res.end) — or calls next(err) → 5) if an error was passed, Express skips remaining normal middleware and jumps straight to the nearest error-handling middleware.

Q: What happens if you call res.json() twice on the same response?

Node throws an "ERR_HTTP_HEADERS_SENT" error — headers can only be sent once. This is a common bug when a handler forgets to return after sending a response and code falls through to send another one.

Q: Why must the error-handling middleware be registered last?

Express matches middleware in registration order and dispatches errors to the first four-argument middleware it encounters after the point of failure. If it's registered before your routes, it simply won't be in the chain when a later route throws.

5. MongoDB & Mongoose

Document Model vs Relational Rows Basic

A relational database (SQL) stores data as rows in fixed-schema tables, related to each other via foreign keys and joined at query time. MongoDB stores data as flexible, JSON-like documents (BSON) inside collections — related data can be nested (embedded) directly inside a document, or split across collections and joined at query time with $lookup. There's no enforced schema at the database level (schema-less), and documents in the same collection can technically have different shapes.

SQL (Relational)MongoDB (Document)
StructureTables, rows, fixed columnsCollections, documents (BSON), flexible fields
RelationshipsForeign keys + JOINsEmbedding or references + $lookup
SchemaEnforced by the DB (DDL)Enforced at the app layer (e.g. Mongoose)
TransactionsNative, mature multi-row ACIDSupported (multi-document ACID since v4.0) but less idiomatic
ScalingTraditionally vertical, harder to shardBuilt for horizontal sharding
Best fitComplex relationships, strong consistency needsRapidly evolving schemas, hierarchical/document-shaped data

Schema Design Philosophy

"Schema-less" doesn't mean "don't design a schema" — it means the database won't enforce one for you, so the discipline shifts to the application layer. The core MongoDB design principle, often summarized as "data that is accessed together should be stored together," is the opposite of relational normalization: you model around your application's query patterns, not around eliminating duplication.

Embedding vs Referencing Intermediate

FactorEmbed when…Reference when…
Relationship type"Contains" / one-to-few (e.g. address inside user)One-to-many-large or many-to-many (e.g. author with 10,000 posts)
Access patternAlmost always read together with the parentOften accessed independently of the parent
Update frequencySub-data rarely changes independentlySub-data changes frequently/independently of parent
Document sizeCombined size stays well under the 16MB document limitEmbedding would risk unbounded document growth
Data duplicationDuplication is acceptable/desired for read speedDuplication would be costly or inconsistent to maintain
💡 Interview Tip

A crisp one-liner for this exact question: "Embed for one-to-few, tightly-coupled, read-together data; reference for one-to-many/many-to-many, independently-accessed, or unboundedly-growing data." Follow up by mentioning the 16MB document size limit as the hard ceiling that forces referencing for large sub-collections (e.g. comments on a viral post).

Indexing in MongoDB

Without an index, MongoDB must perform a collection scan — inspect every document to satisfy a query. An index is a separate, sorted data structure (B-tree) on one or more fields that lets the query planner jump directly to matching documents. Every collection automatically gets an index on _id. Common index types: single-field, compound (multi-field, order matters), multikey (on array fields), text (full-text search), and TTL (auto-expire documents after a time period — great for sessions/temp tokens).

JavaScript
// Compound index — supports queries filtering on status alone,
// or status + createdAt together, but NOT createdAt alone
// (leftmost-prefix rule)
db.orders.createIndex({ status: 1, createdAt: -1 });

// Explain a query to check whether it's using an index (IXSCAN)
// or falling back to a full collection scan (COLLSCAN)
db.orders.find({ status: "pending" }).explain("executionStats");
⚠️ Common Pitfall

Indexes speed up reads but slow down writes (every insert/update must also update each index) and consume extra memory/disk. Over-indexing a write-heavy collection is a common real-world performance mistake — index deliberately, based on actual query patterns, not "just in case."

The Aggregation Pipeline

The aggregation pipeline processes documents through a sequence of stages, each transforming the data before passing it to the next — conceptually similar to Unix pipes. The most-asked stages: $match (filter, like WHERE), $group (aggregate, like GROUP BY), $project (reshape/select fields), $sort, $lookup (a left outer join against another collection).

JavaScript
// "Total revenue per customer in 2025, with customer name joined in"
db.orders.aggregate([
  { $match: { createdAt: { $gte: new Date("2025-01-01") } } },
  { $group: { _id: "$customerId", totalRevenue: { $sum: "$amount" }, orderCount: { $sum: 1 } } },
  { $lookup: {
      from: "customers",
      localField: "_id",
      foreignField: "_id",
      as: "customer"
  } },
  { $unwind: "$customer" },
  { $project: { _id: 0, customerName: "$customer.name", totalRevenue: 1, orderCount: 1 } },
  { $sort: { totalRevenue: -1 } }
]);

Mongoose: Schemas, Validation & populate()

Mongoose is an ODM (Object Document Mapper) that layers schema enforcement, validation, type casting, middleware (hooks), and a friendlier query API on top of the native MongoDB driver — bringing back some of the structure MongoDB itself doesn't enforce.

JavaScript
const mongoose = require("mongoose");

const orderSchema = new mongoose.Schema({
  customer: { type: mongoose.Schema.Types.ObjectId, ref: "Customer", required: true },
  items: [{
    product: { type: mongoose.Schema.Types.ObjectId, ref: "Product" },
    quantity: { type: Number, min: 1, required: true },
  }],
  status: { type: String, enum: ["pending", "shipped", "delivered"], default: "pending" },
  total: { type: Number, required: true, min: 0 },
}, { timestamps: true });

const Order = mongoose.model("Order", orderSchema);

// populate() replaces a reference ObjectId with the actual referenced document
const order = await Order.findById(orderId)
  .populate("customer", "name email")   // only pull name + email fields
  .populate("items.product");
Q: Does populate() perform a real database JOIN?

No — under the hood it issues a separate query against the referenced collection and stitches the results together client-side (in the driver), unlike a SQL JOIN which happens inside the database engine in a single query. This is a key reason heavy, deeply nested populate() chains can become a performance bottleneck.

6. REST API & HTTP Semantics

Idempotency Intermediate

An operation is idempotent if making the same request multiple times produces the same end state as making it once (the response may differ, but the server-side effect doesn't compound). This matters enormously for retries: a client (or proxy) that isn't sure whether a request succeeded can safely retry an idempotent request without fear of duplicating side effects.

VerbIdempotent?Why
GETYesRead-only, no state change
PUTYesReplaces a resource with the given representation — repeating it sets the same end state
DELETEYesResource is gone after the first call; repeating is a no-op (often still 204/404)
HEAD / OPTIONSYesRead-only
POSTNoTypically creates a new resource each time — repeating creates duplicates unless you add your own idempotency-key mechanism
PATCHNot guaranteedDepends on the semantics of the partial update (e.g. "increment counter" is not idempotent; "set field to X" is)

HTTP Status Code Groups

CodeMeaningTypical use
200 OKSuccessStandard successful GET/PUT/PATCH response
201 CreatedSuccess, resource createdSuccessful POST creating a new resource (often with a Location header)
204 No ContentSuccess, no bodySuccessful DELETE, or an update with nothing to return
301 Moved PermanentlyRedirect, permanentURL has permanently changed; clients/search engines should update references
302 FoundRedirect, temporaryTemporary redirect (e.g. post-login redirect)
400 Bad RequestClient errorMalformed request syntax/parameters
401 UnauthorizedClient errorMissing/invalid authentication credentials
403 ForbiddenClient errorAuthenticated but not permitted (authorization failure)
404 Not FoundClient errorResource doesn't exist at this URL
409 ConflictClient errorRequest conflicts with current state (e.g. duplicate unique field, version mismatch)
422 Unprocessable EntityClient errorSyntactically valid but semantically invalid (e.g. failed validation rules)
500 Internal Server ErrorServer errorUnhandled exception on the server
502 Bad GatewayServer errorUpstream/proxy got an invalid response from another server
503 Service UnavailableServer errorServer temporarily overloaded or down for maintenance
Q: 401 vs 403 — what's the precise difference?

401 means "I don't know who you are" — authentication is missing or invalid; the correct client response is to (re-)authenticate. 403 means "I know who you are, but you're not allowed to do this" — authorization failure; re-authenticating won't help.

Q: 400 vs 422 — when would you use each?

400 is for structurally malformed requests (invalid JSON, wrong content-type, missing required field entirely). 422 is for requests that are well-formed and parseable but fail semantic/business validation (e.g. an email field that isn't a valid email, a date range where end is before start).

API Versioning Strategies

  • URI versioning/api/v1/users. Simple, explicit, cacheable, but "pollutes" the URI and implies the resource itself changed.
  • Header versioning — a custom header like Accept: application/vnd.myapi.v2+json. Keeps URIs clean but harder to test/discover (can't just click a link).
  • Query parameter versioning/api/users?version=2. Easy to add, but easy to omit accidentally and less RESTfully "correct."

URI versioning is by far the most common in practice for its simplicity and discoverability, despite being the least "pure" from a REST purist's viewpoint.

Pagination: Offset vs Cursor-Based

Offset-based (?page=3&limit=20)Cursor-based (?after=<id>&limit=20)
ImplementationSimple — SKIP/LIMITNeeds a stable, sortable cursor field (e.g. _id, timestamp)
Performance at scaleDegrades — DB still scans/skips all preceding rowsConsistently fast — index seek directly to the cursor
Consistency under writesItems can shift between pages (skip/duplicate rows) if data changes mid-paginationStable — immune to inserts/deletes before the cursor
Jump to arbitrary page✅ Easy ("go to page 7")❌ Not directly supported (sequential only)
Best forSmall datasets, admin UIs needing page numbersLarge/real-time datasets, infinite scroll feeds

7. Authentication & Authorization

Session-Based Auth vs JWT Intermediate

Session-based auth: on login, the server creates a session record (in memory, Redis, or a DB) keyed by a random session ID, and sends that ID to the browser as a cookie. On each request, the server looks up the session ID to find the associated user — the server holds the state ("stateful").

JWT (JSON Web Token) auth is stateless: on login, the server issues a signed token containing the user's claims (id, roles, expiry) directly. The client sends this token with every request, and the server verifies its signature — no database/session-store lookup needed. A JWT has three dot-separated, Base64URL-encoded parts: header.payload.signature — the header names the algorithm, the payload holds claims (visible to anyone, NOT encrypted — never put secrets in it), and the signature (HMAC or RSA/ECDSA) lets the server verify the token wasn't tampered with.

Session + CookieJWT
StateStateful (server stores session)Stateless (server just verifies signature)
ScalingNeeds a shared session store (e.g. Redis) across serversScales trivially — any server can verify independently
RevocationInstant (delete the session record)Hard — must wait for expiry, or maintain a blocklist (defeats "stateless")
Payload sizeSmall (just an opaque ID)Larger (carries claims on every request)

Where to store a JWT client-side? An httpOnly cookie is inaccessible to JavaScript, so it's immune to theft via XSS — but it's automatically sent on every request to the domain, making it a CSRF target (mitigated with SameSite + CSRF tokens). localStorage is easy to use from JS and immune to CSRF (you attach it manually to requests), but fully exposed to any XSS on the page — a single injected script can read and exfiltrate it. The generally recommended pattern is a short-lived access token plus httpOnly, Secure, SameSite=Strict/Lax cookies, combined with strong XSS hygiene and CSRF defenses.

💡 Interview Tip

Frame this as a trade-off, not a "right answer": httpOnly cookies trade CSRF risk (mitigable with SameSite + tokens) for strong XSS protection; localStorage trades XSS risk (much harder to fully mitigate — any injected script wins) for CSRF immunity. Most security-conscious teams prefer httpOnly cookies because XSS is generally considered the more dangerous, harder-to-fully-prevent class of vulnerability.

JWT Structure & Verification Example

JavaScript
const jwt = require("jsonwebtoken");

// Issue a short-lived access token at login
const token = jwt.sign(
  { sub: user._id, role: user.role },
  process.env.JWT_SECRET,
  { expiresIn: "15m" }
);

// Verify on protected routes
try {
  const payload = jwt.verify(token, process.env.JWT_SECRET);
  // payload = { sub, role, iat, exp }
} catch (err) {
  // TokenExpiredError, JsonWebTokenError, etc.
}

OAuth2 Authorization Code Flow Advanced

OAuth2's authorization code flow is how "Sign in with Google/GitHub" works without your app ever seeing the user's password:

  1. Your app redirects the user to the provider's authorization URL, including your client_id, requested scope, and a redirect_uri.
  2. The user logs in and consents on the provider's own site (your app never touches their credentials).
  3. The provider redirects back to your redirect_uri with a short-lived, single-use authorization code in the query string.
  4. Your backend exchanges that code for an access token (and often a refresh token) by making a server-to-server POST request that also includes your app's client_secret — this step never happens in the browser, keeping the secret safe.
  5. Your backend uses the access token to call the provider's API (e.g. fetch the user's profile/email) and then establishes its own session/JWT for the user.

The authorization code step exists specifically so the access token is never exposed in the browser's URL/history — only the one-time code is, and it's useless without the confidential client_secret to exchange it.

Password Hashing: bcrypt & Salting

Passwords must never be stored in plaintext or with a fast general-purpose hash like plain SHA-256/MD5 — those are designed to be fast, which is exactly the wrong property for password storage: it makes brute-forcing/rainbow-table attacks cheap at scale (billions of hashes/sec on GPUs). bcrypt (and similarly scrypt/Argon2) is deliberately slow and configurable via a "cost factor" (work factor) that can be tuned upward as hardware gets faster, and it automatically generates and embeds a unique random salt per password — so identical passwords produce different hashes, defeating precomputed rainbow-table attacks.

JavaScript
const bcrypt = require("bcrypt");

const SALT_ROUNDS = 12;
const hashed = await bcrypt.hash(plainPassword, SALT_ROUNDS); // salt is generated + embedded automatically
const isValid = await bcrypt.compare(plainPassword, hashed);   // constant-time comparison internally

CSRF (Cross-Site Request Forgery)

CSRF tricks a logged-in user's browser into submitting an unwanted request to a site they're authenticated on — e.g. a malicious page auto-submits a form to bank.com/transfer, and the browser happily attaches the victim's existing session cookie, because cookies are sent automatically for the target domain regardless of which site initiated the request. Defenses: SameSite cookies (Lax or Strict) prevent the browser from sending the cookie on cross-site requests in the first place; CSRF tokens — a random, unpredictable token embedded in forms/headers that the server validates matches what it issued, which an attacker's cross-origin page has no way to read or forge (blocked by the same-origin policy).

CORS (Cross-Origin Resource Sharing)

Browsers enforce the same-origin policy by default — JS on siteA.com can't read responses from api.siteB.com unless the server explicitly opts in. CORS is that opt-in mechanism: the server responds with headers like Access-Control-Allow-Origin: https://siteA.com to permit specific cross-origin callers. For "non-simple" requests (custom headers, methods other than GET/POST/HEAD, or non-form content types like application/json), the browser first sends an automatic preflight OPTIONS request asking the server what's allowed, before sending the real request — CORS exists to protect users, not servers: it's enforced by the browser, so it does nothing against non-browser clients like curl or server-to-server calls.

JavaScript
const cors = require("cors");

app.use(cors({
  origin: ["https://myapp.com"],
  methods: ["GET", "POST", "PUT", "DELETE"],
  credentials: true, // allow cookies to be sent cross-origin
}));
Q: Why does a preflight OPTIONS request happen for some requests but not others?

Browsers only skip preflight for "simple requests" (GET/POST/HEAD, a small allow-list of headers, and content-types limited to form-encoded/plain-text/multipart). Anything else — a custom Authorization header, application/json body, or verbs like PUT/DELETE — triggers a preflight so the browser can confirm the server actually permits it before risking a side-effecting cross-origin request.

8. Web Security Essentials

Cross-Site Scripting (XSS) Intermediate

XSS lets an attacker inject and execute arbitrary JavaScript in a victim's browser, in the context of a trusted site — enabling cookie/token theft, session hijacking, or arbitrary actions as the victim.

  • Stored XSS — malicious script is saved on the server (e.g. in a comment field) and served to every visitor who views that content.
  • Reflected XSS — the payload comes from the request itself (e.g. a query parameter) and is immediately echoed back into the response unescaped, usually delivered via a crafted link.
  • DOM-based XSS — the vulnerability is entirely client-side: JS takes untrusted data (URL, document.referrer) and writes it into the DOM via a dangerous sink (innerHTML, eval) without ever touching the server.

Primary defense: output encoding/escaping based on context (HTML-escape for HTML bodies, attribute-escape for attributes, JS-escape for inline scripts), never trusting user input rendered as HTML. React escapes text content by default — the real danger zone is dangerouslySetInnerHTML, which should only ever receive sanitized HTML (e.g. via DOMPurify).

⚠️ Common Pitfall

Assuming client-side sanitization is enough. An attacker doesn't have to use your UI — they can hit your API directly. Sanitize/validate on the server too, and encode on output regardless of what was done on input.

CSRF Recap

See the Authentication & Authorization section above for the full explanation — in short: SameSite cookies + CSRF tokens are the standard pairing, and state-changing requests should never be triggerable via a plain GET (which can be triggered by something as simple as an <img src>).

SQL / NoSQL Injection

SQL injection occurs when untrusted input is concatenated directly into a query string, letting an attacker alter the query's structure (e.g. ' OR '1'='1). The fix is always parameterized queries / prepared statements, never string concatenation — the driver sends the query and the data separately, so user input can never be interpreted as SQL syntax.

NoSQL injection in MongoDB typically exploits passing raw, attacker-controlled objects into query operators — e.g. if req.body.password is passed unchecked and the attacker sends {"$ne": null} instead of a string, a naively built query like User.findOne({ username, password: req.body.password }) can match any document. Defenses: validate/cast input types strictly (Mongoose schema types help), use libraries like express-mongo-sanitize to strip $/. operators from user input, and never spread raw request bodies directly into query filters.

JavaScript
// VULNERABLE — string concatenation (SQL)
const query = `SELECT * FROM users WHERE email = '${email}'`;

// SAFE — parameterized query
db.query("SELECT * FROM users WHERE email = ?", [email]);

// VULNERABLE — raw body value used directly as a query operator (NoSQL)
User.findOne({ username, password: req.body.password });
// attacker sends: { "username": "admin", "password": { "$ne": null } }

// SAFE — enforce types + sanitize
if (typeof req.body.password !== "string") return res.status(400).end();

Security Headers

HeaderPurpose
Content-Security-PolicyWhitelists allowed sources for scripts/styles/images/etc., dramatically limiting the blast radius of an XSS injection by blocking inline/unauthorized script execution.
X-Frame-OptionsPrevents the page from being embedded in an <iframe> on another site — defends against clickjacking.
Strict-Transport-Security (HSTS)Forces browsers to only ever connect over HTTPS for this domain, even if the user types http://, preventing downgrade/MITM attacks.

In Express, the helmet middleware sets a sensible set of these headers with one line: app.use(helmet()).

Rate Limiting

Rate limiting caps how many requests a client (by IP, user ID, or API key) can make in a given window — it's a core defense against brute-force login attempts, credential stuffing, and API abuse/scraping, and also protects backend resources from being overwhelmed.

JavaScript
const rateLimit = require("express-rate-limit");

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5,                   // 5 attempts per window per IP
  message: "Too many login attempts, please try again later.",
});

app.post("/api/login", loginLimiter, loginHandler);

9. Caching & Performance

Browser Caching: Cache-Control & ETags Intermediate

Cache-Control tells the browser (and any intermediate caches/CDNs) how to cache a response — e.g. max-age=3600 (cache for an hour), no-cache (must revalidate before using a cached copy), no-store (never cache), public/private. An ETag is a hash/fingerprint of the resource's content; on a subsequent request the browser sends it back via If-None-Match, and if unchanged the server replies 304 Not Modified with an empty body instead of re-sending the full resource — saving bandwidth while still guaranteeing freshness.

CDN Basics

A CDN (Content Delivery Network) caches static assets (JS/CSS bundles, images, fonts, sometimes whole HTML pages) at edge servers geographically distributed close to users, reducing latency and offloading traffic from your origin server. Static frontend hosts like Vercel/Netlify serve every deployment through a CDN by default.

Redis for Server-Side Caching

Redis is an in-memory key-value store, commonly used as a cache layer in front of a slower primary database. The most common pattern is cache-aside (a.k.a. lazy loading): on read, check the cache first; on a miss, fetch from the database, populate the cache, then return the value; on write, update the database and invalidate (or update) the corresponding cache entry.

JavaScript
async function getProduct(id) {
  const cacheKey = `product:${id}`;
  const cached = await redisClient.get(cacheKey);
  if (cached) return JSON.parse(cached); // cache hit

  const product = await Product.findById(id); // cache miss — hit the DB
  await redisClient.set(cacheKey, JSON.stringify(product), { EX: 300 }); // TTL 5 min
  return product;
}

async function updateProduct(id, updates) {
  const product = await Product.findByIdAndUpdate(id, updates, { new: true });
  await redisClient.del(`product:${id}`); // invalidate stale cache entry
  return product;
}
Q: What's the risk with cache-aside and how do you mitigate it?

A brief window of staleness between a DB write and cache invalidation, and "cache stampede" — if a hot key expires, many concurrent requests can simultaneously miss and hammer the database at once. Mitigations: short TTLs with jitter, a lock/single-flight pattern so only one request repopulates the cache while others wait, and proactive invalidation on writes rather than relying purely on TTL expiry.

Database Query Optimization

The single highest-leverage database performance tool is proper indexing (see the MongoDB Indexing section) — most slow-query problems trace back to a missing or poorly-designed index causing a full collection/table scan. Beyond indexing: select only the fields you need (projection) instead of whole documents, avoid N+1 query patterns (batch/join instead of looping queries), and use explain()/query planners to verify a query is actually using the index you expect.

Lazy Loading Images & Code Splitting

Native lazy-loading for images defers off-screen images until they're about to enter the viewport: <img src="photo.jpg" loading="lazy" alt="…" /> — cuts initial page weight and speeds up first paint for image-heavy pages. On the JS side, code splitting (via React.lazy + dynamic import(), covered in the React Performance section) achieves the same goal for JavaScript bundles — ship only what's needed for the current view, defer the rest.

10. Testing

Unit vs Integration vs End-to-End Testing Basic

The "testing pyramid" describes the recommended proportion of each: many fast, cheap unit tests (a single function/component in isolation, dependencies mocked) at the base; fewer integration tests (multiple units working together — e.g. an API route hitting a real test database) in the middle; a small number of slow, expensive end-to-end (E2E) tests (a real browser driving the full deployed-like app, e.g. with Cypress/Playwright) at the top, reserved for critical user flows.

Jest Basics

Jest is the most common JS test runner/assertion library. describe groups related tests, it/test defines an individual test case, expect(value).matcher() makes assertions, and lifecycle hooks (beforeEach, afterEach, beforeAll, afterAll) set up/tear down shared state.

JavaScript
const { add } = require("./math");

describe("add()", () => {
  it("adds two positive numbers", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("handles negative numbers", () => {
    expect(add(-1, -1)).toBe(-2);
  });

  it("throws on non-number input", () => {
    expect(() => add("a", 1)).toThrow();
  });
});

React Testing Library Philosophy

RTL's guiding principle: "the more your tests resemble the way your software is used, the more confidence they can give you." Instead of reaching into component internals (state, instance methods, implementation details), you query the rendered DOM the way a user would — by visible text, label, or accessible role — and simulate real interactions (clicks, typing). This makes tests resilient to refactors that don't change user-facing behavior, and brittle only to changes users would actually notice.

JSX
import { render, screen, fireEvent } from "@testing-library/react";
import LoginForm from "./LoginForm";

test("shows an error when submitting with an empty password", () => {
  render(<LoginForm />);

  fireEvent.change(screen.getByLabelText(/email/i), { target: { value: "a@b.com" } });
  fireEvent.click(screen.getByRole("button", { name: /log in/i }));

  expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});

Supertest for API Testing

Supertest lets you make real HTTP-style requests against an Express app in-process (no need to actually bind a port), and chains naturally with Jest assertions — ideal for integration-testing API routes end-to-end (through middleware, controller, and often a real test database).

JavaScript
const request = require("supertest");
const app = require("../app");

describe("POST /api/users", () => {
  it("creates a user and returns 201", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "Ada", email: "ada@example.com" });

    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty("id");
    expect(res.body.email).toBe("ada@example.com");
  });

  it("returns 422 for an invalid email", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "Ada", email: "not-an-email" });

    expect(res.status).toBe(422);
  });
});

11. Deployment & DevOps Basics

Environment Variables & .env Basic

Environment variables externalize configuration (API keys, DB connection strings, secrets) from code, so the same codebase can run against different environments (dev/staging/prod) without code changes. A local .env file (loaded via a package like dotenv) is convenient for development — but it must never be committed to version control (add it to .gitignore); in real deployments, secrets are injected via the hosting platform's environment variable configuration or a secrets manager, not a checked-in file.

bash
# .gitignore
.env
.env.local
node_modules/

CI/CD Concept

Continuous Integration automatically builds and tests every change (often on every push/PR) to catch problems early and keep the main branch always releasable. Continuous Deployment/Delivery automates pushing a passing build to staging/production. A typical pipeline: lint (catch style/obvious bugs fast) → test (unit + integration) → build (bundle/compile/produce artifacts, e.g. a Docker image) → deploy (ship the artifact to the target environment) — each stage gates the next, so a failure stops the pipeline before broken code reaches users.

Docker Basics

An image is an immutable, layered snapshot of everything needed to run an app (code, runtime, dependencies, OS libraries) — built once from a Dockerfile. A container is a running (or stopped) instance of an image — isolated but lightweight, sharing the host OS kernel (unlike a full VM). You can spin up many containers from the same image, each with its own isolated filesystem/process space.

bash
# Dockerfile — minimal Node app
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
bash
docker build -t my-api:latest .
docker run -p 3000:3000 --env-file .env my-api:latest

Reverse Proxy Concept (Nginx)

A reverse proxy sits in front of one or more backend servers and forwards client requests to them, returning the response as if it came from the proxy itself. Common uses: SSL/TLS termination (handle HTTPS once at the proxy, plain HTTP behind it), load balancing across multiple app instances, serving static files directly (faster than routing them through Node), and centralizing security headers/rate limiting/caching in one place instead of every app instance.

bash
# nginx.conf snippet
server {
    listen 80;
    server_name myapp.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Cloud Deployment Basics

A common MERN deployment split: the React frontend is built into static files and deployed to a static-hosting/CDN platform like Vercel or Netlify (git-push-to-deploy, automatic previews, global edge caching). The Node/Express backend needs a long-running process, so it goes to a platform like Render, Railway, or a raw compute instance like AWS EC2 — each trading off convenience (managed platforms handle scaling/SSL/deploys for you) against control (EC2 gives full control but you own provisioning, scaling, and ops). MongoDB itself is typically hosted separately via MongoDB Atlas rather than self-managed.

12. Web System Design Basics

Client-Server Model Recap Basic

Clients (browsers, mobile apps) initiate requests; servers hold data/logic and respond. In a MERN app: the React client renders UI and calls a REST API; the Express server processes business logic and talks to MongoDB for persistence. This separation lets each layer scale and evolve independently.

Horizontal vs Vertical Scaling

Vertical scaling ("scale up") means adding more resources (CPU, RAM) to a single server — simple, no architectural changes needed, but has a hard ceiling (biggest machine money can buy) and a single point of failure. Horizontal scaling ("scale out") means adding more servers/instances and distributing load across them — near-limitless ceiling and better fault tolerance, but requires the app to be stateless (or externalize state) since any request could land on any instance, plus a load balancer to distribute traffic.

Load Balancing

A load balancer sits in front of multiple server instances and distributes incoming requests across them, using strategies like round-robin, least-connections, or IP-hash. It also typically performs health checks, routing traffic away from unhealthy instances automatically. This is why statelessness matters for horizontal scaling — if session data lived only in one server's memory, a request could hit a different instance next time and lose it.

Monolith vs Microservices

MonolithMicroservices
DeploymentSingle deployable unitMany independently deployable services
Development speed (early)Faster — one codebase, simple local setupSlower — more moving parts, inter-service contracts
ScalingScale the whole app togetherScale each service independently based on its own load
Fault isolationOne bug can take down the whole appA failing service can be isolated (with proper design)
Operational complexityLowHigh — service discovery, distributed tracing, network reliability
Best forSmall-to-mid teams, early-stage products, unclear domain boundariesLarge orgs, independently-scaling domains, teams that can own services end-to-end
💡 Interview Tip

A mature answer isn't "microservices are better" — it's "start with a well-modularized monolith; split into microservices only once you have a concrete scaling or team-ownership problem that monolith architecture is actually causing." Premature microservices are a well-known anti-pattern.

Message Queues (RabbitMQ/Kafka)

A message queue decouples producers from consumers: instead of a service calling another service directly and waiting (synchronous), it publishes a message to a queue/topic and moves on — a consumer processes it independently, at its own pace. This is the standard tool for asynchronous processing: sending emails, processing uploaded videos, generating reports, or any slow/unreliable task that shouldn't block the user-facing request. It also smooths out traffic spikes (the queue absorbs bursts) and adds resilience (a crashed consumer can be restarted and pick up where it left off, rather than the request simply failing).

RabbitMQ is a traditional message broker good at flexible routing and per-message delivery guarantees. Kafka is a distributed log built for very high-throughput event streaming, where consumers can replay historical messages — better suited to event-sourcing/analytics-style pipelines than simple task queues.

WebSockets for Real-Time Features

HTTP is request-response — the server can't push data to the client unannounced. WebSockets upgrade a single TCP connection (starting as an HTTP handshake) into a persistent, full-duplex channel where either side can send messages at any time — the foundation for chat apps, live notifications, collaborative editing, and live dashboards. (Alternatives for simpler one-way-push needs: Server-Sent Events for server-to-client-only streams, or polling/long-polling as a fallback.)

Mini System Design: URL Shortener Advanced

Requirements: given a long URL, generate a short unique alias; redirect visitors from the short URL to the original; handle very read-heavy traffic (far more redirects than creations).

API:

bash
POST /api/shorten          { "longUrl": "https://..." } -> { "shortCode": "aZ3kT9" }
GET  /:shortCode           -> 302 redirect to the original long URL

Data model: a single collection/table — { shortCode (indexed, unique), longUrl, createdAt, expiresAt?, clickCount }.

Short code generation: either (a) base62-encode an auto-incrementing counter/ID (guarantees uniqueness, compact, but reveals creation order and needs a centralized counter), or (b) hash the long URL (e.g. MD5) and take the first N characters, checking for collisions and re-hashing with a salt if one occurs.

Scaling considerations: reads (redirects) vastly outnumber writes → put a cache (Redis) in front of the shortCode → longUrl lookup, since it's an immutable, high-repeat-read mapping — ideal cache-aside candidate. The redirect endpoint can be served from many stateless, horizontally-scaled instances behind a load balancer. A CDN/edge layer can cache redirects for extremely hot links. The datastore just needs a unique index on shortCode; sharding by shortCode hash handles horizontal growth if a single DB node becomes the bottleneck.

Mini System Design: Simple Chat App

WebSocket flow: client opens a WebSocket connection to a chat server (authenticated via a token during the handshake) and joins a "room" (e.g. a conversation ID). When a user sends a message, the client emits it over the socket; the server persists it to the database and then broadcasts it to every other socket currently joined to that room. If a recipient is offline, the message just waits in the database until they reconnect and fetch history.

Message persistence: store each message as a document — { conversationId (indexed), senderId, text, createdAt (indexed) } — so recent history for a conversation can be fetched with a simple indexed query, paginated with cursor-based pagination on createdAt/_id for infinite scroll-back.

Scaling consideration: a single Node process holds WebSocket connections in memory, so scaling to multiple server instances requires a shared pub/sub layer (e.g. Redis pub/sub, or a message broker) so a message received by the instance holding sender A's socket gets broadcast to whichever instance holds recipient B's socket.

13. Advanced & Rare Topics

React Fiber Architecture Advanced

Fiber is React's reconciliation engine (rewritten from the older "stack reconciler" starting in React 16). The old reconciler processed the whole component tree recursively and synchronously — once started, it couldn't be paused, so a large update could block the main thread long enough to drop frames and make input feel laggy. Fiber restructures the work into a linked-list-like tree of "fiber" units, each representing a unit of work, which lets React:

  • Pause, abort, or resume work incrementally instead of doing it all in one synchronous pass.
  • Assign priority to different types of updates — e.g. a user typing in an input can be treated as higher priority than a large, non-urgent list re-render happening at the same time (this is the foundation of Concurrent React features like useTransition and startTransition).
  • Reuse completed work or throw it away if it becomes stale before being committed to the DOM.

Rendering happens in two phases: the render phase (interruptible — building the new fiber tree, can be paused/resumed/discarded) and the commit phase (synchronous, uninterruptible — actually applying DOM mutations, so the UI never shows a half-updated state).

SSR vs CSR vs SSG vs ISR

StrategyWhen HTML is generatedTrade-off
CSR (Client-Side Rendering)In the browser, after JS downloads and runsFast subsequent navigations, but slow first paint (blank page until JS loads) and weaker SEO
SSR (Server-Side Rendering)On the server, per-requestFast first paint + good SEO, but higher server load and slower time-to-first-byte under heavy traffic
SSG (Static Site Generation)At build time, onceFastest possible serving (pure static files/CDN), but content is only as fresh as the last build
ISR (Incremental Static Regeneration)At build time, then regenerated in the background after a set interval or on-demandNear-SSG speed with periodically fresh content, without rebuilding the entire site

Hydration is the process that makes server-rendered HTML interactive: the browser downloads the same JS bundle the SSR/SSG process used, React walks the existing server-rendered DOM, attaches event listeners, and reconciles it with what it would have rendered client-side — turning static markup into a fully working React app without re-creating the DOM from scratch (assuming the server and client output match; a mismatch causes a "hydration error").

Microtask vs Macrotask Ordering — Tricky Example Advanced

JavaScript
console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve()
  .then(() => {
    console.log("C");
    return Promise.resolve();
  })
  .then(() => console.log("D"));

Promise.resolve().then(() => console.log("E"));

console.log("F");

// Output: A, F, C, E, D, B
//
// 1) Sync code runs first: "A", then "F" (both logged before anything is queued to run)
// 2) Stack is empty -> drain microtask queue fully:
//    - first .then() logs "C", returns a new resolved promise, queues the next .then as a NEW microtask
//    - the second independent .then() logs "E" (it was queued before D's continuation existed)
//    - the queued continuation from step 1 now runs, logging "D"
// 3) Microtask queue is empty -> move to the next macrotask: setTimeout fires, logs "B"
💡 Interview Tip

The trap most candidates fall into is assuming "D" logs right after "C" because they're chained. It doesn't — returning a promise from a .then() defers the next .then() to a *new* microtask turn, so any microtask already queued before that point (like "E" here) jumps ahead of it. Walk through it step-by-step out loud; this question is really testing whether you understand that the microtask queue is a FIFO queue, not that chained thens execute back-to-back instantly.

Cluster vs Worker Threads — When to Use Which

Recap and deepen the earlier Node section: choose cluster when the bottleneck is I/O concurrency — you want to accept more simultaneous HTTP connections than one event loop can juggle responsively, and each request is cheap CPU-wise (typical REST API). Choose worker_threads when a specific piece of work is CPU-bound — e.g. image resizing, PDF generation, complex data transformation — and would otherwise block the event loop for other requests; you offload just that computation to a thread and get the result back via message passing, keeping the main thread free to keep serving I/O. A production system often uses both: cluster for horizontal request-handling capacity, plus a worker-thread pool (or a separate queue-backed worker service) for CPU-heavy tasks.

MongoDB Write Concern, Read Concern & Read Preference

These three knobs independently control MongoDB's consistency/durability/availability trade-offs, especially in a replica set:

  • Write concern — how many replica set members must acknowledge a write before it's considered successful. w: 1 (just the primary — fast, but a crash before replication could lose the write), w: "majority" (a majority of the set must ack — much safer, slightly slower), w: 0 (fire-and-forget, no acknowledgment at all).
  • Read concern — what guarantee a read gives about the data it returns. "local" (whatever the queried node currently has, possibly not yet replicated/could be rolled back), "majority" (only data acknowledged by a majority of the replica set — won't return data that could later be rolled back).
  • Read preference — which member(s) of the replica set to route reads to: primary (default, always consistent), secondary/secondaryPreferred (spreads read load off the primary, but risks slightly stale data due to replication lag), nearest (lowest latency member, primary or secondary).
💡 Interview Tip

A precise way to summarize: write concern controls durability of writes, read concern controls consistency guarantees of reads, and read preference controls which node reads are routed to. They're independent knobs — e.g. you can route reads to secondaries (read preference) while still requiring majority read concern for correctness.

Idempotency Keys for Safe Retries

POST isn't naturally idempotent, but payment-like APIs (charge a card, place an order) absolutely need retry safety — a network blip that causes a client to resend a charge request must not double-charge the customer. The standard solution: the client generates a unique idempotency key (e.g. a UUID) once per logical operation, and sends it in a header on every retry attempt. The server stores a record of "key → result" the first time it processes that key; on any retry with the same key, it returns the original stored result immediately without reprocessing the operation.

JavaScript
app.post("/api/charge", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];
  if (!idempotencyKey) return res.status(400).json({ error: "Idempotency-Key header required" });

  const existing = await IdempotencyRecord.findOne({ key: idempotencyKey });
  if (existing) {
    return res.status(existing.status).json(existing.responseBody); // replay original result
  }

  const result = await chargeCard(req.body); // the real, side-effecting operation

  await IdempotencyRecord.create({
    key: idempotencyKey,
    status: 201,
    responseBody: result,
  });

  res.status(201).json(result);
});

Rate Limiting Algorithms: Token Bucket vs Leaky Bucket

Token bucket — a bucket holds up to capacity tokens, refilled at a steady rate; each request consumes one token, and is rejected/queued if the bucket is empty. It naturally allows short bursts up to the bucket's capacity while enforcing a steady average rate over time. Leaky bucket — requests enter a fixed-size queue and are processed ("leak out") at a strictly constant rate, smoothing bursts into a perfectly steady output stream regardless of how bursty the input was — no burst allowance, unlike token bucket.

JavaScript
class TokenBucket {
  constructor(capacity, refillRatePerSec) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRatePerSec;
    this.lastRefill = Date.now();
  }

  #refill() {
    const now = Date.now();
    const elapsedSec = (now - this.lastRefill) / 1000;
    const newTokens = elapsedSec * this.refillRate;
    this.tokens = Math.min(this.capacity, this.tokens + newTokens);
    this.lastRefill = now;
  }

  tryConsume() {
    this.#refill();
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return true; // request allowed
    }
    return false; // rate limited
  }
}

const bucket = new TokenBucket(10, 2); // capacity 10, refills 2 tokens/sec
if (!bucket.tryConsume()) {
  // respond 429 Too Many Requests
}

14. Practical Coding

Minimal Express REST API + Mongoose Model Intermediate

JavaScript
// models/Task.js
const mongoose = require("mongoose");

const taskSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true },
  done: { type: Boolean, default: false },
}, { timestamps: true });

module.exports = mongoose.model("Task", taskSchema);

// routes/tasks.js
const router = require("express").Router();
const Task = require("../models/Task");

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

// GET /api/tasks
router.get("/", asyncHandler(async (req, res) => {
  const tasks = await Task.find().sort({ createdAt: -1 });
  res.json(tasks);
}));

// POST /api/tasks
router.post("/", asyncHandler(async (req, res) => {
  const task = await Task.create({ title: req.body.title });
  res.status(201).json(task);
}));

// PUT /api/tasks/:id
router.put("/:id", asyncHandler(async (req, res) => {
  const task = await Task.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
  if (!task) return res.status(404).json({ error: "Task not found" });
  res.json(task);
}));

// DELETE /api/tasks/:id
router.delete("/:id", asyncHandler(async (req, res) => {
  const task = await Task.findByIdAndDelete(req.params.id);
  if (!task) return res.status(404).json({ error: "Task not found" });
  res.status(204).end();
}));

module.exports = router;

// app.js
const express = require("express");
const mongoose = require("mongoose");
const app = express();

app.use(express.json());
app.use("/api/tasks", require("./routes/tasks"));

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

mongoose.connect(process.env.MONGO_URI).then(() => {
  app.listen(3000, () => console.log("Server running on port 3000"));
});

Custom Hook: useDebounce

JSX
function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timerId = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timerId); // cancel if value changes before delay elapses
  }, [value, delay]);

  return debouncedValue;
}

// Usage: search input that only fires the API call after the user pauses typing
function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;
    fetchSearchResults(debouncedQuery);
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />;
}

JWT Auth Middleware for Express

JavaScript
const jwt = require("jsonwebtoken");

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization; // "Bearer <token>"
  const token = authHeader && authHeader.split(" ")[1];

  if (!token) {
    return res.status(401).json({ error: "No token provided" });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role }; // attach user to req
    next();
  } catch (err) {
    if (err.name === "TokenExpiredError") {
      return res.status(401).json({ error: "Token expired" });
    }
    return res.status(401).json({ error: "Invalid token" });
  }
}

// Optional: role-based authorization built on top of authenticate
function requireRole(role) {
  return (req, res, next) => {
    if (req.user?.role !== role) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }
    next();
  };
}

// Usage
app.get("/api/admin/reports", authenticate, requireRole("admin"), getReports);

References & Further Reading