The 60 Express questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Express is a minimal, unopinionated web framework for Node.js, first released in 2010 and now stewarded by the OpenJS Foundation. It doesn't hide Node's http module; it wraps it with routing, a middleware pipeline, and convenience helpers on the request and response objects, then gets out of your way. That thin design is why it stays popular: you assemble the pieces you need instead of accepting a full-stack framework's opinions. In interviews, Express questions center on one idea, the middleware chain, because routing, body parsing, authentication, and error handling all flow through it. The official Express documentation describes middleware as functions with access to the request, the response, and the next function in the cycle, and understanding that contract is what most Express questions are really checking. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Node.js and Express.js - Full Course
Video: Node.js and Express.js - Full Course (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Express certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Express is a minimal, unopinionated web framework for Node.js. It sits on top of Node's built-in http module and adds routing, a middleware pipeline, and helper methods on the request and response objects. It handles the tedious parts of an HTTP server, matching routes and chaining middleware, without forcing an architecture on you.
Teams use it because it handles the tedious parts of an HTTP server, parsing URLs, matching routes, chaining middleware, without forcing an architecture. You keep control of structure while skipping the boilerplate of raw Node.
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Hello from Express");
});
app.listen(3000, () => console.log("listening on 3000"));Key point: A one-line definition plus the phrase 'thin layer over Node's http module' beats a feature list. Interviewers open with this to hear how you frame the framework.
Watch a deeper explanation
Video: Express JS Crash Course (Traversy Media, YouTube)
Middleware is a function that runs during the request-response cycle with access to the request object, the response object, and the next function. It can read or change req and res, end the response, or call next() to pass control to the following middleware.
Everything in Express flows through this chain: logging, body parsing, authentication, routing, and error handling are all middleware. Understanding the chain is understanding Express.
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next(); // hand off to the next middleware
});Key point: Say the three-part signature (req, res, next) and that next() is what continues the chain. That one sentence answers half of every Express interview.
Watch a deeper explanation
Video: Learn Express Middleware In 14 Minutes (Web Dev Simplified, YouTube)
req is the request object: it holds incoming data like req.params (route parameters), req.query (query string), req.body (parsed body), and req.headers. res is the response object: you send data back with res.send(), res.json(), res.status(), and res.redirect(). Together they represent one HTTP exchange from arrival to reply.
Both wrap Node's raw IncomingMessage and ServerResponse and add convenience methods, so you rarely touch the raw Node objects directly.
app.get("/users/:id", (req, res) => {
const id = req.params.id; // route param
const sort = req.query.sort; // ?sort=name
res.status(200).json({ id, sort });
});A route pairs an HTTP method and a URL path with a handler function: app.get, app.post, app.put, app.delete, and so on. When an incoming request matches both the method and the path, Express runs that handler and stops looking. Routes are checked in the order you register them, so the first match wins.
Paths can be literal (/users), parameterized (/users/:id), or pattern-based. app.all matches every method, and app.route lets you chain handlers for one path.
app.get("/products", (req, res) => res.send("list products"));
app.post("/products", (req, res) => res.send("create product"));
app.get("/products/:id", (req, res) => res.send(`product ${req.params.id}`));Key point: the key signal is 'method plus path plus handler'. Mention route parameters with the colon syntax and you have covered the follow-up.
Watch a deeper explanation
Video: How to build a REST API with Node js & Express (Programming with Mosh, YouTube)
Route parameters are named segments in the path itself, defined with a colon and read from req.params, so /users/:id captures the id. Query strings are key-value pairs after the ? in the URL, read from req.query, so /search?term=node gives you the term. One is part of the resource path, the other is optional extra data.
Use route params to identify a specific resource and query strings for optional filters, sorting, or pagination. That convention maps cleanly to REST.
// GET /users/42/posts?limit=10
app.get("/users/:userId/posts", (req, res) => {
req.params.userId; // "42"
req.query.limit; // "10"
res.end();
});| Route parameter | Query string | |
|---|---|---|
| Where | In the path (/users/:id) | After ? (?sort=name) |
| Read from | req.params | req.query |
| Typical use | Identify a resource | Filter, sort, paginate |
| Required? | Usually yes | Usually optional |
res.send() sends a string, buffer, or object and sets sensible headers for you. res.json() serializes to JSON explicitly. res.status(code) sets the status. res.redirect() sends a redirect, res.sendFile() streams a file, and res.end() ends the response without a body. Each of these is a terminating method that finishes the request.
You call exactly one terminating method per request. Calling res.send() twice throws the 'headers already sent' error, one of the most common Express mistakes.
res.status(201).json({ created: true });
// res.send("again"); // would throw: headers already sentKey point: The 'you can only respond once' rule is a favorite trap. Volunteering it shows you have hit the headers-already-sent error and understood why.
Register the built-in express.json() middleware, which reads the raw request body, parses it as JSON, and puts the result on req.body for your handlers to use. Without it, req.body is undefined for JSON requests because Express doesn't parse bodies by default. You opt in with this one line of middleware.
Since Express 4.16 this is built in, so you don't need the separate body-parser package anymore. Use express.urlencoded() for form-encoded bodies.
app.use(express.json());
app.post("/login", (req, res) => {
const { email } = req.body; // populated by express.json()
res.json({ email });
});Key point: The classic gotcha: register express.json() before the routes that read req.body. Order in the middleware chain is the whole answer.
Use the built-in express.static middleware, pointing it at a directory on disk. Files in that folder are then served directly at the root path, or under a mount path you choose, without writing a route for each one. Express matches the URL to a file name and streams it back with the right Content-Type.
This is how you serve HTML, CSS, images, and client JavaScript without writing a route for each file.
// serve everything in ./public at the root
app.use(express.static("public"));
// or mount under a prefix
app.use("/assets", express.static("public"));app.use() mounts middleware that runs for any HTTP method whose path starts with the given prefix, which defaults to /. app.get() registers a handler that runs only for GET requests matching the exact path. So app.use() is for cross-cutting concerns like logging or auth, and the method functions handle the actual endpoints.
So app.use() is for cross-cutting middleware (logging, parsing, auth), and the method functions (get, post, put, delete) are for actual endpoints.
app.use("/api", authMiddleware); // all methods under /api
app.get("/api/users", listUsers); // only GET /api/usersnext() passes control to the next matching middleware or route in the stack. It's the mechanism that keeps the request-response cycle moving. Without it, a middleware that doesn't send a response leaves the request hanging, and the client eventually times out waiting for a reply that never comes.
Calling next('an error') or next(err) skips ahead to the error-handling middleware. Calling next('route') skips the rest of the current route's handlers and moves to the next matching route.
app.use((req, res, next) => {
if (!req.headers.authorization) {
return next(new Error("no token")); // jump to error handler
}
next(); // continue the chain
});Key point: Knowing that next(err) routes to the error handler while plain next() continues normally is the distinction interviewers probe.
Initialize a Node project with npm init, install Express with npm install express, then create an entry file that requires express, creates an app with express(), defines at least one route, and calls app.listen on a port. Running that file with node starts the server. From there you add middleware and routes as the app grows.
That's the whole minimum. From there you add middleware and routes as the app grows.
npm init -y
npm install express
node index.jsExpress has a method function for each HTTP verb: app.get, app.post, app.put, app.patch, app.delete, app.head, app.options, and more. Each one registers a handler that runs only for requests using that verb at the matching path, so the same path can behave differently for a GET than for a POST.
In a REST API the mapping is conventional: GET reads, POST creates, PUT and PATCH update, DELETE removes. app.all handles a path for every method at once.
| Method | REST intent | Express call |
|---|---|---|
| GET | Read a resource | app.get() |
| POST | Create a resource | app.post() |
| PUT / PATCH | Update a resource | app.put() / app.patch() |
| DELETE | Remove a resource | app.delete() |
res.json() always serializes its argument to JSON and sets Content-Type to application/json, no matter what you pass. res.send() is more general: it sends strings, buffers, or objects, and when you hand it an object it serializes to JSON too. For an API, res.json() makes the intent explicit and handles edge cases like null cleanly.
In practice res.json() makes your intent explicit for API responses and also handles edge cases like sending null cleanly.
res.json({ ok: true }); // Content-Type: application/json
res.send("<h1>Hi</h1>"); // Content-Type: text/htmlCall res.status(code) to set the status, then chain a terminating method that actually sends the response. For example, res.status(404).send('Not found') sets the code and sends the body in one line, and res.status(201).json(obj) does the same for a created resource. The status call on its own sends nothing.
res.sendStatus(code) sets the status and sends the standard status message as the body in a single call, handy for empty responses.
res.status(201).json({ id: 7 }); // created
res.sendStatus(204); // No Content, empty bodyRead values from process.env, falling back to sensible defaults, like const port = process.env.PORT || 3000. Deployment platforms set PORT for you at runtime, and NODE_ENV tells you whether you're in development or production so you can toggle behavior. This keeps config out of the code and specific to each environment.
For local development, a package like dotenv loads a .env file into process.env. Keep secrets out of the repo and pass config in through the environment.
const port = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === "production";
app.listen(port);Add a catch-all middleware after all your routes. Because middleware runs in registration order, any request that didn't match an earlier route falls through to it, and there you respond with a 404. It has no special API; it's simply the last non-error middleware in the chain, so anything unmatched lands on it.
It's not a special API; it's just the last middleware in the chain. Placement is everything: put it after your routes, before the error handler.
// after all routes
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});Key point: The insight the question needs: 404 handling is 'the request reached the end of the chain unmatched', not a built-in feature. That reveals you understand ordering.
A template engine renders HTML from template files with embedded data placeholders. Express supports engines like EJS, Pug, and Handlebars: you set the view engine, put templates in a views folder, and call res.render() with the data to inject. Express runs the engine and sends the finished HTML back as the response.
For server-rendered pages this replaces string concatenation. Many modern Express apps skip it and serve JSON to a separate frontend instead.
app.set("view engine", "ejs");
app.get("/", (req, res) => {
res.render("home", { name: "Asha" });
});express.Router() creates an isolated group of routes and middleware, a mini-application, that you mount onto a path with app.use. It keeps a growing app from becoming one giant file: related routes live in their own module, and the mount path prefixes all of them, so a users router mounted at /users owns every /users route.
You define related routes in their own module, export the router, and mount it with app.use('/users', usersRouter). Routes inside are relative to the mount path.
// users.js
const router = require("express").Router();
router.get("/", listUsers); // GET /users
router.get("/:id", getUser); // GET /users/:id
module.exports = router;
// app.js
app.use("/users", require("./users"));Yes. A route handler has the exact same (req, res, next) signature as middleware; the only difference is that a handler is bound to a specific method and path. A single route can even have several handlers that run in order, each calling next() to reach the one after it. Seeing that routes and middleware share one shape makes the framework click.
Seeing that routes and middleware are the same shape is a small insight that makes the whole framework click.
app.get("/dashboard", checkAuth, loadUser, (req, res) => {
res.json({ user: req.user }); // runs after the two middlewares
});The raw http module gives you a request and response and nothing else: you parse URLs, match methods, and set headers by hand. Express adds a router, the middleware chain, body parsing, static file serving, and dozens of response helpers.
You can build a server without Express, but you'd reinvent routing and middleware. Express is that common layer, standardized and battle-tested.
Use a tool like nodemon, which watches your source files and restarts the Node process automatically whenever you save, so you don't stop and start the server by hand after every edit. Recent Node versions also ship a built-in --watch flag that does the same thing without an extra dependency. Either one speeds up the edit-run loop.
It's a development convenience only; in production you run plain node behind a process manager or container.
npx nodemon index.js
# or, recent Node:
node --watch index.jsFor candidates with working experience: middleware judgment, error handling, async patterns, and the questions that separate users from understanders.
Error-handling middleware has four arguments instead of three: (err, req, res, next). Express recognizes it purely by that four-argument signature, not by name, and calls it only when an error is passed via next(err) or thrown in a synchronous handler. You register it last so it can catch errors from every route and middleware above it.
You register it last, after all routes and other middleware, so it catches everything. One central error handler keeps error formatting consistent across the app.
// registered after all routes
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});Key point: The four-argument signature is the whole tell. Interviewers watch for whether you know Express detects error handlers by arity, not by name or registration method.
Watch a deeper explanation
Video: Express.js & Node.js Course for Beginners - Full Tutorial (freeCodeCamp.org, YouTube)
In Express 5, a rejected promise from an async handler is forwarded to your error middleware automatically, so a plain async function with an await that throws just works. In Express 4 it doesn't: an unhandled rejection escapes Express entirely.
On Express 4 you wrap handlers in try/catch and call next(err), or use a small wrapper (or the express-async-errors package) that catches rejections for you.
// Express 4 wrapper pattern
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get("/user/:id", wrap(async (req, res) => {
const user = await db.find(req.params.id); // if this throws, wrap catches it
res.json(user);
}));Key point: Naming the Express 4 vs 5 difference here is a production signal. Many candidates don't know that unhandled async rejections silently escaped Express 4.
Middleware runs in registration order. A body parser registered after a route means req.body is undefined in that route. An auth check registered after the protected route never runs. The error handler registered before the routes never sees their errors.
So the mental model is a pipeline: put parsing and logging first, then auth, then routes, then the 404 handler, then the error handler last.
A correct middleware ordering
Almost every 'req.body is undefined' or 'auth never runs' bug traces back to registration order.
Key point: Give the concrete failure (req.body undefined because the parser ran too late). A specific broken case beats reciting 'order matters'.
Application-level middleware is bound with app.use or app.METHOD and runs for the whole app. Router-level middleware is the same but bound to a router instance. Error-handling middleware has the four-argument signature. Built-in middleware ships with Express (express.json, express.static). Third-party middleware comes from npm (cors, helmet, morgan).
| Level | Bound to | Example |
|---|---|---|
| Application | app | app.use(express.json()) |
| Router | a Router instance | router.use(auth) |
| Error-handling | app or router, 4 args | app.use((err, req, res, next) => {}) |
| Built-in | Express itself | express.static('public') |
| Third-party | npm packages | app.use(cors()) |
CORS (cross-origin resource sharing) is a browser rule that blocks requests from a page on one origin to an API on another unless the API sends the right headers. When your frontend and API live on different domains, the browser enforces it.
The cors middleware sets those headers for you. Configure it to allow specific origins and methods rather than opening it to everyone in production.
const cors = require("cors");
app.use(cors({
origin: "https://app.example.com",
methods: ["GET", "POST"],
}));Use the helmet middleware, which sets a group of HTTP response headers that reduce common attack surface. It removes the X-Powered-By header that advertises Express, sets X-Content-Type-Options to stop MIME sniffing, configures a default Content-Security-Policy, and adds several others. One line gives you the defaults most apps should have.
It's one line and covers defaults most apps should have. You then tune the Content-Security-Policy for your own assets.
const helmet = require("helmet");
app.use(helmet());Pass a limit option to the body parser, like express.json({ limit: '100kb' }), so Express rejects anything larger before it fully buffers. Without a cap, a client can send a huge payload that exhausts memory, which is a simple denial-of-service vector. The limit protects the process from both accidents and abuse.
Set the limit to the largest legitimate request your API accepts. Oversized requests then get rejected with a 413 before they're fully buffered.
app.use(express.json({ limit: "100kb" }));Split routes into Router modules by resource, like users, orders, and products, each in its own file, then mount them under a base path with app.use. Add versioning by mounting under /api/v1. Keep handlers thin and push real logic into service functions, so the routing layer stays readable. This turns one giant file into a predictable folder structure.
This turns one thousand-line file into a predictable folder structure a new teammate can handle without a map.
app.use("/api/v1/users", require("./routes/users"));
app.use("/api/v1/orders", require("./routes/orders"));req.params holds named route segments, so /users/:id gives req.params.id. req.query holds parsed query string values, so ?page=2 gives req.query.page. req.body holds the parsed request body, but only after a body-parsing middleware like express.json() has run. Each source maps to a different part of the incoming request.
A frequent bug: reading req.body without registering express.json() first, so it's undefined. Each source maps to a different part of the request.
// PATCH /users/42?notify=true { "name": "Sam" }
app.patch("/users/:id", (req, res) => {
req.params.id; // "42"
req.query.notify; // "true"
req.body.name; // "Sam" (needs express.json())
res.end();
});Pass several functions to a single route and each runs in order, calling next() to hand off to the next one. This is how you compose per-route middleware, like a validation step, then an auth step, then the final handler that sends the response. It keeps cross-cutting checks out of the handler body and reusable across routes.
app.route() gives a cleaner form for one path across methods, and arrays of middleware let you reuse handler groups.
app.route("/articles")
.get(listArticles)
.post(validateBody, requireAuth, createArticle);Write a small middleware that logs the method, URL, and response timing, or use the morgan package for ready-made access logs. Register it early in the chain so it sees every request, including ones that later fail or 404. Logging after the routes would miss anything that short-circuits before reaching it.
For production, structured JSON logs from a logger like pino feed better into log aggregation than plain text.
const morgan = require("morgan");
app.use(morgan("tiny")); // logs each request lineMap each resource to routes by HTTP method: GET for reads, POST to create, PUT or PATCH to update, DELETE to remove. Parse bodies with express.json(), validate input, call your data layer, and respond with the right status code and JSON.
Return 201 on create, 204 on delete, 400 on bad input, and 404 when a resource isn't found. Consistent status codes are what makes an API predictable.
app.get("/todos", (req, res) => res.json(todos));
app.post("/todos", (req, res) => {
const todo = { id: nextId(), ...req.body };
todos.push(todo);
res.status(201).json(todo);
});
app.delete("/todos/:id", (req, res) => {
todos = todos.filter((t) => t.id !== Number(req.params.id));
res.sendStatus(204);
});res.locals is an object scoped to a single request and reset for the next one. Middleware can attach data to it, like the authenticated user or a generated request id, and later middleware, handlers, or rendered views can read it back. It's the clean way to pass computed values down the chain without touching global state.
It's the clean way to pass computed values down the chain for one request without polluting global state or the req object's own fields.
app.use((req, res, next) => {
res.locals.requestId = crypto.randomUUID();
next();
});
// later handler or view can read res.locals.requestIdexpress.json() only parses JSON, not multipart form data, so file uploads need a dedicated middleware like multer. It parses multipart/form-data requests, writes each uploaded file to disk or memory, and puts the file metadata on req.file or req.files for your handler. You then validate the file type and size before saving it.
You then validate file type and size before saving, because uploads are a common security and storage risk.
const multer = require("multer");
const upload = multer({ dest: "uploads/" });
app.post("/avatar", upload.single("photo"), (req, res) => {
res.json({ file: req.file.filename });
});Use res.set(name, value) or res.header() to set a response header, and req.get(name) to read an incoming request header. res.redirect(url) sends a redirect that defaults to a 302 (temporary), and res.redirect(301, url) makes it a permanent one. You can set several headers at once by passing an object to res.set.
Set headers before sending the body. Once the response starts, you can't change headers, which is the root of the 'headers already sent' error.
res.set("Cache-Control", "no-store");
res.redirect(301, "/new-path");Branch on process.env.NODE_ENV: enable detailed error output and looser CORS in development, then tighten both in production so you don't leak stack traces or open your API to every origin. Keep the values themselves in environment variables, not hardcoded in the source, so the same build runs anywhere.
app.get('env') reads the same NODE_ENV. Load a .env file with dotenv locally, and let the deployment platform inject real values in production.
if (app.get("env") === "development") {
app.use(errorDetailsMiddleware); // verbose errors locally
}Use express-rate-limit as middleware. It counts requests per client, usually by IP address, inside a rolling time window and rejects anything over the limit with a 429 status. This blunts brute-force login attempts and scraping without any code in your handlers. Apply it globally or scope it to sensitive routes like login.
For anything distributed across multiple servers, back the limiter with a shared store like Redis so the count is global, not per process.
const rateLimit = require("express-rate-limit");
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // per IP per window
}));Validate in middleware before the handler runs, using a library like express-validator, Joi, or Zod. Check required fields, types, and formats, then respond with a 400 and a clear message when the input is bad, before any business logic touches it. Validating at the edge keeps handlers clean and shields the data layer from garbage.
Validating at the edge keeps handlers clean and protects the data layer from garbage. Never trust req.body, req.query, or req.params directly.
const { body, validationResult } = require("express-validator");
app.post("/signup",
body("email").isEmail(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
res.json({ ok: true });
});Capture the server object that app.listen returns, then listen for SIGTERM or SIGINT and call server.close(), which stops accepting new connections while letting in-flight requests finish. Once close completes, tear down database pools and other resources, then exit. This is what makes a deploy or scale-down drain cleanly instead of dropping active requests.
Without this, a deploy or scale-down can cut off active requests mid-response. Graceful shutdown is what makes rolling deploys safe.
const server = app.listen(3000);
process.on("SIGTERM", () => {
server.close(() => {
db.end();
process.exit(0);
});
});advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
Express keeps an ordered stack of layers, each pairing a path pattern with a handler. For each request it walks the stack top to bottom, compiling each route's path into a regular expression and testing method and path. The first matching layer runs, and next() advances to the next candidate.
So routing is linear and order-dependent, not a hash lookup. That's why a broad app.use() placed early can intercept requests meant for a later specific route.
Key point: Saying 'ordered stack, first match wins, paths compile to regex' shows you understand routing as sequential matching rather than magic dispatch.
The headline change is async error handling: Express 5 forwards a rejected promise from an async handler to the error middleware automatically, which Express 4 did not. Path matching also tightened, some deprecated method signatures were removed, and the router internals were modernized.
For interviews, the async-rejection behavior is the one that changes how you write handlers. In 5 you can drop most of the try/catch-plus-next boilerplate.
| Area | Express 4 | Express 5 |
|---|---|---|
| Async handler rejection | Escapes Express, must wrap | Forwarded to error handler |
| Path matching | Looser patterns allowed | Stricter, safer patterns |
| Node baseline | Older versions supported | Newer Node required |
Key point: The async-rejection difference is the answer that matters. Candidates who only know Express 4 keep writing wrappers Express 5 made unnecessary.
next() tells the router to invoke the next layer whose method and path match the request. Internally the router holds an index into its layer stack; next() advances that index and calls the corresponding handler. Passing an argument (next(err)) tells the router to skip ahead to the next error-handling layer instead.
Because it's a continuation, calling next() twice in one middleware runs the rest of the chain twice, a subtle bug that often shows up as 'headers already sent'.
Key point: The 'calling next() twice runs the chain twice' insight separates people who have debugged Express from people who have only read the docs.
A single Node process uses one core. To use all cores, run one Express process per core behind a load balancer: Node's cluster module forks workers sharing a port, or you run multiple containers and let the orchestrator balance them. The second approach is more common in production because the platform handles restarts and health checks.
Either way, keep the app stateless, so any worker can handle any request. Push shared state (sessions, caches) into Redis or a database.
Scaling an Express service
Statelessness comes first. Without it, adding processes just moves the bugs around.
Key point: Lead with 'make it stateless', then scaling. the key point is that you know a single Node process is one core before you reach for cluster.
Define an error class hierarchy (a base AppError with fields like statusCode and a public message, plus specific subclasses). Handlers throw or next() those errors. One central error middleware maps them to responses: known operational errors return their status and message, unknown errors return a generic 500 and get logged in full.
The line that matters is operational versus programmer errors: a missing record is a 404 you handle, a null dereference is a bug you log and fix. Leaking stack traces to clients is both a security and a professionalism failure.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
app.use((err, req, res, next) => {
if (err.isOperational) return res.status(err.statusCode).json({ error: err.message });
console.error(err);
res.status(500).json({ error: "Internal server error" });
});Model resources as nouns and use HTTP methods as the verbs: GET /orders, POST /orders, GET /orders/:id, PATCH /orders/:id, DELETE /orders/:id. Version the API under a prefix like /api/v1, return correct status codes, and keep every response consistent in shape so clients can rely on it. Predictable URLs and status codes are what make an API easy to consume.
Layer the code: routers hold routing only, controllers handle request and response, services hold business logic, and a data layer talks to the database. Thin controllers and reusable services keep the app testable as it grows.
// router -> controller -> service
router.get("/:id", ordersController.getById);
// controller
exports.getById = async (req, res, next) => {
try {
const order = await orderService.find(req.params.id);
if (!order) return res.status(404).json({ error: "Not found" });
res.json(order);
} catch (e) { next(e); }
};Key point: The layering answer (router, controller, service, data) is what the question needs for 'how would you structure this'. It shows you have maintained an Express app past the toy stage.
Watch a deeper explanation
Video: RESTful APIs in 100 Seconds // Build an API from Scratch with Node.js Express (Fireship, YouTube)
Two common models. Sessions: on login you create a server-side session and set a session-id cookie; middleware loads the session on each request. JWT: on login you sign a token the client sends in an Authorization header; middleware verifies the signature and attaches the user, with no server-side session store.
JWT scales statelessly but is hard to revoke before expiry; sessions revoke instantly but need a shared store. Put the verification in middleware so every protected route reuses it.
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Unauthorized" });
}
}
app.get("/me", requireAuth, (req, res) => res.json(req.user));| Concern | Sessions | JWT |
|---|---|---|
| State | Server-side store | Stateless (in the token) |
| Revocation | Instant (delete session) | Hard before expiry |
| Scaling | Needs shared store | No store needed |
| Best for | Server-rendered apps | APIs, mobile clients |
Test handlers and middleware in isolation as plain functions where you can, and test routes end to end with supertest, which drives the app in memory without binding a real port. Assert on status codes, response bodies, and side effects like a database write. Keep a few integration tests against a real test database for confidence.
Export the app separately from the code that calls app.listen, so tests import the app directly. Mock external boundaries (database, third-party APIs) and keep a few integration tests against a real test database for confidence.
const request = require("supertest");
const app = require("../app");
test("GET /health returns 200", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
});Key point: 'export app, call listen elsewhere' matters. Coupling app.listen into the same module is a common blocker.
Because res is a Node writable stream, you can pipe a readable stream straight into it, like fs.createReadStream(path).pipe(res). That sends the data in chunks without ever loading the whole file into memory, and Node handles backpressure for you so a slow client doesn't overwhelm the server. It's how you serve large files or long exports safely.
For generated data, write chunks and call res.write() repeatedly, then res.end(). Streaming is how you serve large files or long database exports without blowing up memory.
app.get("/export", (req, res) => {
res.setHeader("Content-Type", "text/csv");
fs.createReadStream("big-report.csv").pipe(res);
});Node runs your handlers on a single event-loop thread. A synchronous CPU-heavy loop or a blocking call freezes that thread, so every other request in flight has to wait, no matter how many are queued behind it. Throughput collapses under one slow handler, and even health checks start timing out. This is the classic Node performance trap.
The fixes: move CPU work off the loop with worker threads or a separate service, use async I/O so waits don't block, and never call the sync versions of fs functions in a request path.
// bad: blocks the event loop for every request
app.get("/hash", (req, res) => {
const digest = expensiveSyncHash(req.query.data); // freezes everyone
res.send(digest);
});
// better: offload to a worker thread or async libraryKey point: This is a Node question wearing an Express costume. the question needs 'single event-loop thread, blocking work stalls all requests', then the offload strategy.
Enable gzip or brotli with the compression middleware to shrink response bodies over the wire. Set Cache-Control and ETag headers so clients and proxies can reuse responses. For expensive computed responses, cache the result in memory or Redis keyed by the request.
In production, terminate compression and TLS at a reverse proxy or CDN when you can, so Express spends its cycles on application work rather than encoding.
const compression = require("compression");
app.use(compression());
app.get("/report", (req, res) => {
res.set("Cache-Control", "public, max-age=300");
res.json(buildReport());
});When Express runs behind a reverse proxy or load balancer, the client's real IP and protocol arrive in X-Forwarded headers, not on the socket. app.set('trust proxy', ...) tells Express to trust those headers so req.ip, req.protocol, and secure-cookie logic reflect the original client.
Get this wrong and rate limiting sees the proxy's IP for everyone, and secure cookies over HTTPS-terminated-at-the-proxy don't get set. It's a small setting with outsized production impact.
app.set("trust proxy", 1); // trust the first proxy hop
// now req.ip and req.protocol reflect the real clientLayer defenses: helmet for security headers, cors locked to known origins, rate limiting on auth and write endpoints, body-size limits, input validation on every route, and secrets in environment variables. Never leak stack traces to clients, and keep dependencies patched.
Beyond middleware, run over HTTPS, set httpOnly and secure cookies, and log security-relevant events. No single setting is enough; production safety is the sum of these layers.
Choose Express for small to mid services, prototypes, or when the team wants full control and the widest middleware ecosystem. Choose NestJS when a large team needs enforced structure, dependency injection, and TypeScript conventions out of the box. Choose Fastify when raw throughput and schema-based validation matter most.
The honest coverage names the cost of Express too: no enforced structure means discipline is on you, and its per-request overhead is higher than Fastify's. Picking on requirements rather than familiarity is the signal.
| Need | Reach for | Because |
|---|---|---|
| Full control, small team | Express | Minimal, largest ecosystem |
| Enforced structure, large team | NestJS | DI, modules, TypeScript-first |
| Peak throughput, JSON validation | Fastify | Schema-based fast serialization |
| Modern async middleware | Koa | Built around async/await |
Keep the database out of your route handlers. Create a connection pool once at startup, expose data access through a repository or service layer, and have controllers call those functions instead of running queries inline. Reuse the pool across requests rather than opening a connection per request, which would exhaust the database. That boundary is what keeps the app testable.
Handle connection errors and pool exhaustion explicitly, and shut the pool down on graceful shutdown. Whether you use a query builder, an ORM, or raw queries, the boundary between web layer and data layer is what keeps the app testable.
// db.js: one pool for the process
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// service uses the pool
async function findUser(id) {
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [id]);
return rows[0];
}Express itself speaks HTTP, not the WebSocket protocol, but they share a server. You create the HTTP server, pass it to both Express and a WebSocket library like ws or Socket.IO, and the library handles the upgrade requests while Express handles normal routes.
So the technical answer is: Express handles the REST side, the WebSocket library handles the real-time side, and they coexist on one port through the same underlying http.Server.
const http = require("http");
const server = http.createServer(app);
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => ws.send("connected"));
server.listen(3000);Every registered middleware that matches a request runs on that request, so a long global chain adds work to every call. The fix is scoping: mount middleware only where it's needed (on a router or a route) instead of globally, and avoid redundant parsers or loggers.
Also skip work early: return fast for health checks, short-circuit auth on public routes, and don't parse bodies on GET requests. Placement and scope, not micro-optimizing each function, is where the wins are.
Key point: The insight is scope, not speed of individual functions. 'Mount middleware where it's needed instead of globally' is the answer that indicates production experience.
Observe first: check logs and metrics for which route and status, then look at request duration and error rate. Is the event loop blocked (rising latency across all routes), is one endpoint slow (a query or external call), or is it a spike in a specific error? Correlate with recent deploys and dependency changes.
Then confirm with a tool: an event-loop lag metric, an APM trace, or a heap snapshot for a memory climb. Fix at the right layer (a missing index, a blocking call, a missing timeout) and verify the metric returns to normal.
Key point: observe, hypothesize, verify, fix, confirm is the technical point. Reaching for one favorite tool before observing is the anti-pattern they watch for.
Set timeouts at every layer: a server-level request timeout so a stuck request doesn't hold a connection forever, and per-call timeouts on database queries and outbound HTTP so a slow dependency can't hang your handler indefinitely. On timeout, respond with a 503 or 504 rather than leaving the client waiting.
The failure to avoid is unbounded waits: without a downstream timeout, one slow third-party API can exhaust your connections and cascade into an outage.
// abort a downstream fetch after 3s
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 3000);
try {
const r = await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(t);
}Express wins when you want a small, well-understood core and full control over structure, which fits most APIs and services. It trades built-in structure and raw throughput for simplicity and the largest middleware ecosystem in Node. Where it loses is opinionated architecture (NestJS gives you that), peak requests-per-second (Fastify's schema-based serialization is faster), and modern async ergonomics out of the box (Koa was built around async/await from the start). Naming these trade-offs out loud is itself an interview signal: it shows you pick a framework on merits, not habit.
| Framework | Style | Best at | Watch out for |
|---|---|---|---|
| Express | Minimal, unopinionated | APIs, full control, huge ecosystem | No enforced structure, older callback style |
| Fastify | Minimal, schema-first | High throughput, JSON validation | Smaller ecosystem, plugin encapsulation to learn |
| NestJS | Opinionated, structured | Large teams, DI, TypeScript-first | Heavier, steeper learning curve |
| Koa | Minimal, async-native | Modern middleware with async/await | Fewer built-ins, smaller community |
Prepare in layers, and practice out loud. Most Express rounds move from concept questions to live coding a route or middleware, then to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Express interview flow
Earlier rounds increasingly run as AI coding interviews. The middleware and routing concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Express questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works