Prepare for Express.js developer interviews with questions grouped by experience level, from routing basics to microservices architecture.
Junior (0-2 years)
A minimal web framework for Node.js, built on top of Node's own http module. Writing a raw HTTP server by hand for anything beyond a toy example gets tedious fast, routing, parsing request bodies, handling different HTTP methods. Express wraps all of that in a small, well-understood API, which is why it became the default choice for building Node APIs for years.
const express = require('express'); const app = express();. That's the app object everything else attaches to, routes, middleware, configuration. Calling app.listen(3000) after defining your routes starts the server listening on that port.
The http module gives you the raw building blocks, listening for requests and writing responses, but nothing else. You'd have to write your own routing logic, your own way of parsing a request body, your own error handling structure. Express provides all of that already built, which is exactly why almost nobody builds a real Node API directly on the bare http module today.
app.listen(3000, () => console.log('Server running')). The port number is often pulled from an environment variable in real applications, process.env.PORT || 3000, so the same code can run on a fixed local port during development and whatever port a hosting platform assigns in production.
It registers middleware, a function that runs for every incoming request matching a given path (or every request at all, if no path is specified). It's the mechanism behind nearly everything in Express beyond direct route handlers, parsing bodies, serving static files, logging, applying it all globally in one place instead of repeating logic across every route.
express() creates the main application object, the thing you actually call listen() on. express.Router() creates a smaller, self-contained set of route handlers that can be mounted onto the main app, which is how larger Express applications organize routes into separate files instead of cramming everything into one.
app.get('/users', (req, res) => { res.send('list of users') }). The first argument is the path, and the second is the handler function, called with the request and response objects whenever a GET request hits that exact path.
app.get, app.post, app.put, app.patch, and app.delete cover the common REST verbs. app.all() matches every HTTP method for a given path, useful for middleware-style logic that should run regardless of which verb was used.
Use a colon-prefixed parameter in the route definition, app.get('/users/:id', ...), and Express makes that value available on req.params.id inside the handler. Multiple parameters can appear in one path, like /users/:userId/posts/:postId.
Route parameters (req.params) come from named segments defined directly in the path itself, like the :id in /users/:id. Query string parameters (req.query) come from the part of the URL after the question mark, like ?page=2, and don't need to be declared in the route path at all.
Add a catch-all middleware after every other route is defined, app.use((req, res) => { res.status(404).send('Not found') });. Since Express checks routes in the order they're registered, anything reaching this final middleware genuinely didn't match anything earlier.
app.route('/users').get(handler1).post(handler2) lets you define multiple HTTP method handlers for the same path in one chained statement, instead of repeating the full path across several separate app.get() and app.post() calls. It keeps related route logic for the same resource grouped together more tightly.
A function with access to the request, the response, and a next function, sitting somewhere in the chain of processing a request before it reaches its final handler. Middleware can modify the request or response, end the request-response cycle directly, or call next() to pass control to whatever comes after it.
The request hangs indefinitely, since nothing tells Express to move forward or to finish handling it. Eventually the client's request will time out with no response ever coming back, which is one of the more confusing bugs to debug the first time it happens, since there's no error, just silence.
Built-in middleware that parses an incoming request body formatted as JSON and makes it available on req.body. Without registering it with app.use(express.json()), req.body would be undefined even if the client sent a perfectly valid JSON payload.
Built-in middleware for serving static files, like images, CSS, or client-side JavaScript, directly from a specified folder. app.use(express.static('public')) makes every file inside the public folder accessible directly by its filename in the URL, with no route needing to be written for each individual file.
Application-level middleware is bound directly to the app object with app.use(), applying across the whole application. Router-level middleware is bound to a specific express.Router() instance instead, applying only to routes defined on that particular router, which is useful for scoping middleware, like an authentication check, to just one section of the API.
Pass it as an extra argument directly in the route definition, app.get('/admin', authMiddleware, (req, res) => {...}), rather than registering it globally with app.use(). Express runs authMiddleware first, and only calls the final handler if authMiddleware calls next().
req.params for route parameters, req.query for the query string, req.body for a parsed request body, req.headers for the request's headers, and req.method for the HTTP verb used. These cover the vast majority of what a typical route handler actually needs to read from an incoming request.
res.send() can accept a string, a Buffer, or an object, and Express figures out the appropriate content type automatically. res.json() specifically serializes its argument to JSON and sets the content type header accordingly, which makes the intent explicit even though res.send() would often produce the same practical result for a plain object.
res.status(404).send('Not found') chains a status code onto the response before sending the body. Without explicitly calling status(), Express defaults to a 200 OK response, even in cases like an error, which is why explicitly setting the status code matters for anything other than a straightforward success.
res.redirect('/login') sends a redirect response, defaulting to a 302 status code. Passing an explicit status first, res.redirect(301, '/new-path'), lets you specify a permanent redirect instead when that's actually what the situation calls for.
req.get('Authorization') or req.headers.authorization both retrieve a specific header's value, with req.get() handling the header name case-insensitively for you. This is the standard way to pull something like a bearer token or a custom API key out of an incoming request.
res.download('/path/to/file.pdf') sends the file and sets headers prompting the browser to download it rather than display it inline, unlike res.sendFile(), which sends the file for the browser to render or display directly if it's a type the browser knows how to show.
A view engine renders server-side templates into HTML, combining a template file with data before sending the response. app.set('view engine', 'ejs') configures Express to use EJS, one of the more common choices, after which res.render('index', { data }) renders that template with the given data.
res.render('profile', { name: 'Anu', age: 25 }) renders the profile template (profile.ejs, for example, if using EJS), making the name and age values available for the template to reference and display directly within its own markup.
Server-side rendering builds complete HTML pages on the server, sent ready to display, which used to be the standard approach for a full Express application. Returning JSON instead hands raw data to a separate frontend, commonly a React or Vue application, which then handles rendering entirely on the client side. Most new projects today lean toward the JSON API approach, with server-side rendering more often reserved for content-heavy sites where SEO and initial load speed matter more.
Put them in a dedicated folder, commonly named public, and serve that folder with app.use(express.static('public')). Files inside then become directly accessible by path, so public/style.css becomes reachable at /style.css without any route needing to be written for it specifically.
Register express.static() multiple times with different paths and mount points, app.use('/uploads', express.static('uploads')) and app.use('/assets', express.static('public')), each independently serving its own folder under its own URL prefix.
If an error is thrown inside a synchronous handler, Express catches it automatically and passes it to error-handling middleware. For an asynchronous handler, an error inside a Promise or an async function needs to be passed explicitly to next(error), or it won't be caught automatically, at least in versions of Express before 5, which changed this behavior.
Error-handling middleware is defined with four parameters instead of the usual three or two, (err, req, res, next), and Express recognizes that specific signature to route errors to it. It's typically registered last, after all other routes and middleware, so it can catch anything that gets passed to next(error) anywhere earlier in the chain.
Define one error-handling middleware function at the end of the middleware stack, logging the error and sending an appropriate status code and message back to the client, rather than scattering try-catch blocks with duplicated error-response logic across every individual route.
Calling next() with no argument passes control to the next regular middleware or route handler in the chain. Calling next(error) with an argument skips ahead past any remaining regular middleware, straight to the nearest error-handling middleware instead, which is exactly how you signal that something went wrong.
Rather than sending a plain 404 response directly, create an actual Error object with a 404 status attached and pass it to next(error) from your catch-all not-found handler, letting it flow through the same centralized error-handling middleware everything else uses, so every error response, 404 included, comes back in one consistent shape.
A stack trace can reveal internal file paths, library versions, and implementation details that make an attacker's job easier. Production error handling should log the full details server-side for debugging, while sending the client a generic message that doesn't leak anything about the application's internals.
Mid-Level (3-6 years)
Use express.Router() to define related routes in their own file, like a userRoutes.js handling everything under /users, then mount that router onto the main app with app.use('/users', userRoutes). This keeps route files focused on one resource each, rather than one enormous file handling every route in the whole application.
A dedicated validation middleware, often built with a library like Joi or express-validator, checks the incoming data against a schema and calls next(error) if it fails, before the actual route handler's logic ever runs. This keeps validation logic separate and reusable, rather than duplicating manual checks inside every handler.
app.param('userId', callback) runs the given callback whenever a route containing a :userId parameter is matched, commonly used to look up a resource by that ID once and attach it to the request object, so every route handler using that parameter doesn't need to repeat the same lookup logic.
Mount separate routers under different path prefixes, app.use('/api/v1', v1Router) and app.use('/api/v2', v2Router), letting two versions of the same resource's routes coexist side by side. It's the most explicit and easiest approach for API consumers to understand, even if it's not the only versioning strategy available.
Express treats routes as case-sensitive and distinguishes trailing slashes by default, both configurable through app.set(), though the more common practice is simply being consistent about how routes are defined in the first place, rather than relying on Express to smooth over inconsistency after the fact.
A function taking (req, res, next), logging details like the method, path, and timestamp, then calling next() to let the request continue. app.use((req, res, next) => { console.log(req.method, req.path); next(); }) is the simplest version, though a production application would typically use a proper logging library instead of a raw console.log.
Express runs middleware strictly in the order it's registered, so a piece of middleware that depends on something set up by an earlier one, like an authentication check depending on a body already being parsed, needs to come after it. Registering middleware in the wrong order is a common source of a bug that's genuinely confusing to track down, since the code all looks correct in isolation.
Check the condition inside the middleware function itself, and either run your logic and call next(), or just call next() immediately to skip it entirely for requests that don't meet the condition. Express doesn't have a built-in conditional middleware mechanism beyond writing that check yourself inside the function.
A middleware that ends the response, calling something like res.send() or res.json(), stops the request right there, and any middleware or route handler registered after it never runs for that request. Calling next() instead passes control forward, letting the chain continue.
Write a middleware function that checks for a valid session or token, calling next() if the check passes, or responding with a 401 and not calling next() if it fails. Applying that same middleware to every route needing protection, rather than duplicating the check inside each individual handler, is exactly the pattern middleware exists for.
Resources are represented as nouns, not verbs, in the URL path, /users rather than /getUsers, and the HTTP method itself indicates the action. GET for reading, POST for creating, PUT or PATCH for updating, DELETE for removing. Following this convention consistently makes an API's behavior far more predictable to anyone integrating with it.
PUT is meant to replace a resource entirely, so the request body should contain the complete updated representation. PATCH is meant for a partial update, changing only the specific fields included in the request. Both are implemented the same way structurally in Express, app.put() or app.patch(), but the handler's actual update logic should reflect that difference in intent.
Define a standard error response format, something like { error: { message, code } }, and route every error through the same centralized error-handling middleware so that shape gets applied consistently, rather than letting individual routes each format their own errors slightly differently.
Accept page and limit as query parameters, req.query.page and req.query.limit, apply them to the underlying database query (skip and limit, or an equivalent), and return metadata like total count and total pages alongside the actual data, so the client knows how to request subsequent pages.
Beyond URI versioning for the API itself, a tool like Swagger (OpenAPI) generates interactive, always-current documentation directly from annotations or a schema definition in the code, which holds up far better over time than a separate, manually maintained document that inevitably drifts out of sync with the actual API.
Using Mongoose, connect once at application startup with mongoose.connect(connectionString), then define schemas and models representing your collections. Route handlers then use those models to query and modify data, rather than talking to the MongoDB driver directly in most cases.
Either the raw pg driver for direct SQL queries, or an ORM like Sequelize or Prisma for a more structured, model-based approach. The choice usually comes down to how much control you want over the actual SQL versus how much boilerplate you're willing to trade away for that control.
In its own dedicated module, initialized once when the application starts, then imported wherever it's needed, rather than creating a new connection inside every route handler. Reusing a single connection (or connection pool) is both more efficient and avoids the resource exhaustion that comes from opening a fresh connection on every single request.
Wrap the database call in a try-catch, and in the catch block, call next(error) to route it into your centralized error-handling middleware, rather than letting an unhandled rejection crash the process or leave the client without any response at all.
supertest lets you make requests directly against your Express app object in memory, exactly as if it were running, without binding to a real network port. self.request(app).get('/users').expect(200) is the typical shape of a test written this way.
Using Jest's mocking, replace the model or database function the route depends on with a mock returning a canned response, so the test doesn't need a real database connection at all. This keeps the test fast, deterministic, and able to simulate error cases you couldn't easily trigger against a real database.
Testing the handler function directly means calling it with mock req and res objects, which is faster but skips over the actual routing and middleware layers entirely. Testing through supertest exercises the full request pipeline, routing, middleware, the handler itself, which catches integration issues a direct function call would completely miss.
Call the middleware function directly with mock req, res, and a jest.fn() standing in for next, then assert on how it modified the request or response, or whether it called next() with the arguments you expected. This tests the middleware's actual logic without needing a full route or a real request to trigger it.
Senior (6-8 years)
The compression middleware package, applied early with app.use(compression()), automatically compresses response bodies for clients that support it, which can meaningfully reduce payload size and improve load times for larger JSON responses or HTML pages, at a small CPU cost on the server.
The helmet package sets a range of security-related headers automatically, protections against clickjacking, content sniffing, and enforcing HTTPS in supporting browsers, without configuring each one manually. It's usually one of the first middleware packages added to a production Express app, right alongside CORS handling.
The express-rate-limit package, applied as middleware, tracks request counts per IP (or per authenticated user) within a configured time window, rejecting requests once they cross a defined threshold. For a multi-instance deployment, the counters need to live in a shared store like Redis rather than in memory, so limits are enforced consistently across every instance.
Sanitize incoming request data, stripping out MongoDB operator characters like $ and dot notation before it reaches a query, using a package like express-mongo-sanitize as middleware. Without this, a maliciously crafted request body could manipulate a query's logic in ways the original code never intended.
For a route whose data doesn't change on every request, middleware backed by Redis or an in-memory store can check for a cached response before running the actual handler, returning the cached version if present and storing a fresh one if not. This is especially effective for read-heavy endpoints where the underlying data changes far less often than the endpoint gets called.
Add timing instrumentation around suspected slow operations, database calls, external API requests, or use a proper profiling tool like clinic.js for a fuller picture, rather than guessing based on which piece of code looks the most complex. More often than not, a slow route traces back to an unoptimized database query, not the Express or Node layer itself.
Middleware near the top of the stack generates a unique request ID, attaches it to the request object, and a logging library configured to include it automatically stamps every subsequent log line for that request with the same ID. This makes it possible to filter logs down to exactly one request's full journey through the application, instead of trying to piece it together from a stream of interleaved logs from many concurrent requests.
A common structure separates routes, controllers (the actual handler logic), services (business logic and data access), and models into their own folders, so a route file stays thin, just wiring a path to a controller function, while the real logic lives in a layer that's easier to test independently of Express itself.
Controllers contain the logic that runs in response to a request, coordinating between the request, the service or data layer, and the response sent back. Business logic that doesn't actually depend on the request or response objects is usually better placed in a separate service layer, keeping controllers focused specifically on request handling rather than becoming a dumping ground for all application logic.
Pass dependencies into route handlers or controllers explicitly, often through a factory function that takes the dependencies and returns the actual handler, rather than importing and instantiating them directly inside each file. This keeps testing straightforward, since a mock dependency can be passed in during a test instead of the real one.
Each module exports its own express.Router() instance, and a central application bootstrap file discovers and mounts each one dynamically, either through explicit imports or by scanning a designated plugins folder. This lets new functionality be added by dropping in a new module, without editing a growing, centralized list of routes by hand.
Cramming validation, business logic, and database access all directly inside a single route callback. It works fine at first, but becomes genuinely hard to test or reuse once that same logic needs to be called from somewhere else, like a background job, and there's no separate function to call, just a route handler tightly coupled to the request and response objects.
Log the incoming request details immediately, then hook into the response's finish event to log the outcome, status code and response time, after the response has actually been sent to the client. Doing the logging work after res.on('finish') fires means it never delays the response itself, since the client has already received it by that point.
Middleware checks an incoming request for a header, commonly X-API-Key, validates it against a store of known valid keys, and calls next() if it matches, or rejects the request with a 401 otherwise. This suits machine-to-machine access, like a partner integration, better than a full login flow, which assumes an actual human user going through a session or token-based login.
Lead (8-10 years)
A single Node process, including the Express app running inside it, only uses one core by default. The built-in cluster module, or a process manager like PM2, spins up multiple worker processes sharing the same port, letting the operating system distribute incoming connections across them.
Vertical scaling means running the same Express process on a bigger machine. Horizontal scaling means running multiple instances, whether via clustering on one machine or across several machines behind a load balancer. Since a single Node process is capped by one core's throughput, horizontal scaling, more instances, is the far more common approach for genuinely scaling an Express application beyond what one process can handle.
In-memory session storage doesn't work once there's more than one instance, since a user's session might land on a different instance than the one that created it. A shared session store, commonly Redis, keeps session data accessible to every instance regardless of which one handles a given request.
A dedicated /health endpoint returning a simple 200 status lets the load balancer know an instance is alive and should keep receiving traffic. For a more thorough check, the endpoint can also verify critical dependencies, like the database connection, are actually reachable, rather than only confirming the Express process itself is running.
Listen for the process termination signal, stop accepting new connections immediately, but let requests already in progress finish before actually exiting. Node's http server object exposes a close() method for exactly this, and skipping it is a common cause of a deployment silently dropping requests that were mid-flight.
The cors middleware package supports a dynamic origin function instead of a single hardcoded allowed origin, checking the incoming request's origin against a list of allowed domains and responding accordingly. This is necessary once more than one frontend domain legitimately needs to call the same API.
A timeout middleware, or a timeout configured directly on outgoing requests to slow dependencies (a database call, an external API), ensures a hung request fails after a bounded period rather than tying up server resources indefinitely. Without this, a single slow downstream dependency can eventually exhaust available connections and take down an entire Express instance.
Express's minimal footprint and fast startup suit the container-based, frequently-scaled nature of microservices well. Its simplicity also means less framework-specific behavior to reason about when a service's whole job is doing one thing well and communicating with other services, rather than housing a large, complex application on its own.
The gateway, itself an Express application, receives all incoming traffic and routes requests to the appropriate backend service, often using a package like http-proxy-middleware to forward requests transparently. It's also a natural place to centralize concerns like authentication and rate limiting, so individual backend services don't each need to reimplement them.
Synchronous HTTP calls, often with a library like axios, are simplest but couple the caller's availability to the callee's uptime. Asynchronous, event-driven communication through a message broker decouples the services instead, at the cost of eventual consistency and a genuinely harder debugging story across a chain of async events.
A circuit breaker library like opossum stops calling a failing downstream service after a threshold of failures, failing fast instead of letting requests pile up waiting on something that's already struggling. Pairing that with a bounded retry for genuinely transient failures keeps one failing service from cascading into a wider outage.
Propagate a correlation ID through every request, generated at the entry point and passed along in headers to every downstream call, then logged consistently by each service. Middleware at the start of the Express pipeline is the natural place to generate or extract that ID and attach it to the request for the rest of that request's handling.
I'd weigh it by ownership and deployment independence rather than assuming smaller services are automatically better. If a separate team owns it, it needs to scale or fail independently, or it has meaningfully different reliability requirements, that argues for a new service. Otherwise, adding a well-organized module to the existing service usually ships faster and costs less to operate.
Staff (10+ years)
Run it incrementally. Get the codebase and its dependencies passing under both versions where possible, lean on existing test coverage to catch regressions early, and roll the upgrade out service by service rather than all at once. A full stop-everything migration is rarely something the business will actually tolerate.
I look at whether it's solving the real problem or just its symptom, what happens when a downstream dependency fails rather than only the happy path, and whether it's consistent with patterns already established elsewhere in the system. An inconsistent one-off pattern becomes a maintenance burden the whole team inherits later, so I'd rather ask pointed questions that surface the team's own blind spots than hand them a prescribed answer.
Automate what can be automated, linting, formatting, dependency version alignment, enforced in CI so standards aren't a matter of opinion in code review. For architectural conventions that resist automation, I'd document the handful of decisions that actually matter, with the reasoning behind them, rather than a long style guide nobody reads end to end.
I'd look at where the actual pain is coming from, slow deploys because unrelated teams keep colliding in the same codebase, a specific component needing to scale independently, or genuine team-ownership conflicts, rather than assuming a monolith is inherently a problem just because it's grown large. Splitting it apart is worth the real cost of that migration only once it's actively causing one of those specific, named problems.
I'd weigh the concrete, measurable benefits, Fastify's performance advantage, NestJS's built-in structure and dependency injection, against the real cost of a team having to learn a new framework and the loss of whatever accumulated Express-specific tooling and knowledge already exists. For most teams already productive in Express, that switch is worth making only when a specific, named limitation of Express is genuinely blocking something important, not as a general modernization exercise.
First rule out environmental differences, connection pool sizing, data volume, and whether staging traffic actually resembles production concurrency. Then I'd want structured logging and tracing to see where time is actually going, since 'intermittent under load' is very often event-loop blocking from a synchronous operation somewhere in a middleware or route handler, something low staging traffic would never surface.
A basic health endpoint checking process liveness is the floor, not the ceiling. I'd extend it with checks for what actually matters, database connectivity, downstream dependency availability, and I'd track request latency and error rate over time, alerting on meaningful deviation from baseline rather than only on an outright crash.
Treat the API's routes and response shapes as a contract. Additive changes, a new optional field, a new endpoint, are generally safe. Changing or removing something existing needs a documented deprecation period and direct communication with consumers before removal, rather than a silent breaking change that surfaces as someone else's outage.
Mitigation before root-causing. Roll back a recent deploy, restart affected instances, or shed load if that stops the bleeding, even before fully understanding why it broke. I'd also make sure one person is clearly driving the incident and communicating status, since incidents usually drag on longer because of diffuse ownership, not a lack of technical skill in the room.
Start from actual load testing at realistic traffic shapes rather than linear extrapolation, since the real bottleneck, a database, a downstream API's rate limit, a single blocking operation in a hot path, rarely scales linearly and often shows up well before the target load. I'd identify the actual constraint first, then decide whether the fix is more instances, caching, or redesigning the hot path itself.
This is a judgment question interviewers use to see how you reason under uncertainty, not to test a specific fact. A strong answer names the actual constraint that forced the decision, the realistic options that were genuinely on the table, why you picked one knowing it wasn't guaranteed to be right, and what you'd do differently with what you know now.
I'd take one of their actual routes and walk through what happens when that same business logic needs to run from somewhere else, a background job, a second endpoint, and let them see firsthand why it's now stuck inside a function tightly coupled to req and res. That concrete pain point tends to motivate separating concerns far more than a general lecture on layered architecture.
I wouldn't push a disruptive full restructuring. I'd pick one genuinely painful area, a file that keeps causing merge conflicts or that nobody wants to touch, and refactor just that piece as a visible example, letting the team feel the difference on code they already recognize. Migrating incrementally, new code following the better structure, old code updated opportunistically when it's touched anyway, tends to succeed where a mandated big rewrite usually stalls.
I'd bring the actual data behind my position, load test results, real incident history, current resource utilization, rather than a general preference for one approach over another. Most disagreements like this resolve once both sides are looking at the same concrete numbers instead of arguing from differing assumptions about how the system behaves under load.
I'd translate the risk into numbers leadership already tracks: the specific incident history of that service breaking, hours spent firefighting it each time, and how much slower new features actually ship because every change to it requires extra caution. Framed as a velocity and risk problem with real, already-incurred cost behind it, it competes far better for prioritization than framed as a general code-quality concern.




