LearnThatStack Ace your next interview

Express.js

Express 5 on Node.js 18+. Express 4 differences are marked. Checked Nov 2026 106 code snippets

The model

  • An Express app is one ordered list of middleware and routes. Express walks it in the order you registered them and skips what doesn't match.
  • Middleware gets req, res and next. It sends a response or calls next(). If it does neither, the request hangs.
  • next(err) or a thrown error skips the normal functions and jumps to the error handler, the one with four parameters.
  • A 404 isn't an error. It means the list ran out and nothing sent a response.
Middlewareparse, log, authRoute handlersapp.get(path, fn)404 handlerplain middlewareError handlerfour parametersNORMALnext()nothing answeredERRORSnext(err)throwrejected promise
Any step can send a response and stop there. The coloured arrow is new in Express 5.

Apps & routes

start a server and read what the client sent

What changed in Express 5

Name a wildcard, instead of plain /*/*splat
Mark a path part optional, instead of ?/users{/:id}
Replace app.del() and res.send(status)app.delete()res.sendStatus()
Know what else changedreq.param() is gone, and req.query is read-only.

TipA rejected promise now reaches the error handler, as long as the route returns or awaits it.

TrapString paths reject regular expressions, so :id(\d+) fails.

Middleware & its order

the chain every request walks

Middleware and next()

Move on to the next matchnext()
Jump to the error handlernext(err)
Skip the rest of this routenext('route')
Stop there when more code followsreturn next();
Name the five middleware typesApplication-level, router-level, error-handling, built-in, third-party.

TrapA middleware that neither responds nor calls next() hangs the request.

Order of execution

app.use(logger);every requestapp.use('/api', auth);only paths under /apiapp.get('/api/users', list);sends, so the chain stopsapp.use(notFound);only if nothing answeredapp.use(onError);only after an error

TipParsers and auth go first. The 404 and error handlers go last.

Split routes with Router

Create a routerconst router = express.Router();
Mount it under a prefixapp.use('/users', router);
Run middleware for that router onlyrouter.use(requireAuth);

TipA router is a mini-application with its own middleware and routes, but no server.

Errors & 404s

send every failure to one handler

Write the error handler

Register it after every routeapp.use((err, req, res, next) => {});
Pass the error on if the response has startedif (res.headersSent) return next(err);
Catch async errors in Express 4Promise.resolve(fn(req, res, next)).catch(next);

TipIn Express 4, a rejected promise goes unhandled and crashes the process by default since Node.js 15.

TrapExpress spots it by its four parameters, so keep next even when unused.

Handle 404s

After every route, catch the unmatched requestsapp.use((req, res) => res.sendStatus(404));
Report a missing recordSend 404 from the route, or pass an error with that status.

TrapA 404 isn't an error, so the error handler never sees it.

Custom error classes

Make a base error classclass AppError extends Error {}this.isOperational = true;
Respond to an operational errorSend its message and status code.
Respond to a programmer error in productionHide the details behind a generic 500.

TipAn operational error is an expected failure, such as a missing record. A programmer error is a bug.

Responses & pages

send data, files and rendered HTML

res.send, res.json or res.end

res.send('<p>Hi</p>');Send text or HTML
res.json(null);Send JSON, even null
res.status(204).end();End with no body, using Node's own method
Express sets the headerExpress adds nothing

TipCall only one per request. The first call ends the response.

Serve static files

Serve public/logo.png at /logo.pngapp.use(express.static('public'));
Add a virtual path prefixapp.use('/static', express.static('public'));

TipUse path.join(__dirname, 'public'), because the path is relative to where you start node.

TrapExpress 5 ignores dotfiles by default, so .well-known files return 404.

Content negotiation

Pick the format from the Accept headerres.format({ 'text/html': fn });
Get the best match yourselfreq.accepts(['json', 'html'])

Tipres.format() adds Vary: Accept, so caches keep the versions apart.

TrapWith no match and no default callback, Express passes a 406 error to next().

Parse & validate input

turn raw bytes into checked data

Parse the request body

Parse JSONapp.use(express.json());
Parse a URL-encoded formexpress.urlencoded({ extended: true })
Use the older package, built in since 4.16bodyParser.json()

TipA body over limit, 100kb by default, gets 413.

TrapWithout a parser, req.body is undefined. File forms need multer.

Validate a request

Check one fieldbody('email').isEmail()
Collect the errors, then answer 400validationResult(req)
Read only the checked fieldsmatchedData(req)Extra fields such as isAdmin stay out.

TrapChains only record errors. They never reject a request on their own.

Upload files with multer

Accept one file, on req.fileupload.single('file')
Accept several, on req.filesupload.array('photos')
Cap each file's sizemulter({ limits: { fileSize } })

Trapfile.originalname comes from the client, so treat it as untrusted.

Login, roles & sessions

who is calling, and what they may do

Check a JWT in middleware

Verify the Bearer tokenjwt.verify(token, secret, cb)
Guard one routeapp.get('/me', auth, fn);

TipOn success, set req.user to the decoded payload.

Trap401 means a missing or bad token. 403 means a known user without permission.

Role-based access control

Allow only some roles on a routeauthorize('admin', 'manager')
Deny by default on a whole sectionrouter.use(authorize('admin'));
Check access to one recordAdd an ownership check in the handler. Roles don't cover it.

TrapRead roles from your database or a verified token, never from the client.

Sessions and cookies

Add sessionssession({ secret, resave: false, saveUninitialized: false })
Keep a value for the next requestreq.session.userId = user.id;
Stop session fixation at loginreq.session.regenerate(cb)

TipA cookie holds about 4KB in the browser. A session stores data on the server, with only its ID in a cookie.

TrapMemoryStore leaks memory and works in one process. Use Redis or a database.

CORS, headers & rate limits

control who may call the API, and how often

Enable CORS

Allow every originapp.use(cors());
Allow your own domainscors({ origin: ['https://a.com'] })
Allow cookiescors({ origin, credentials: true })Browsers reject * with credentials.

Tipcors() answers the OPTIONS preflight the browser sends first.

TrapCORS only controls what browsers may read. It isn't access control.

Security headers with Helmet

Set safe defaultsapp.use(helmet());
Name two headers it setsContent-Security-Policy limits where scripts load from. Strict-Transport-Security forces HTTPS.

TipHelmet also removes X-Powered-By.

TrapKeep 'unsafe-inline' out of scriptSrc. Use a nonce or a hash.

Rate limit with express-rate-limit

Allow 100 requests per 15 minutes, then 429rateLimit({ windowMs: 900000, limit: 100 })app.use('/api', limiter);
See the real IP behind a load balancerapp.set('trust proxy', 1);

Tipmax is the older name of limit. Add a stricter limiter on the login route.

TrapThe default store counts per process. With several instances, use Redis.

Rate limit algorithms

windowswindow 1window 2requests100100time

With a limit of 100, bursts on each side of a boundary let 200 through.

Count per clock windowFixed window. Cheap, but allows twice the limit across a boundary.
Store a timestamp per requestSliding window log. Exact, but memory grows with traffic.
Blend the current and previous countsSliding window counter. Close to exact and cheap.
Cap bursts and set a steady averageToken bucket. Bucket size caps a burst, refill rate sets the average.

TrapRedis commands sent one at a time can race. A Lua script runs them atomically.

Caching & databases

reuse results and keep writes consistent

Cache and compress responses

Let browsers and CDNs reuse datares.set('Cache-Control', 'max-age=60');Use private for per-user data.
Read Redis first, save on a missclient.get(key)client.setEx(key, ttl, json)Save only a 200, never a 404.
Invalidate when the data changesclient.del(key)
Compress with gzip, deflate or Brotliapp.use(compression());

TipBody parsers inflate compressed requests. Compression costs CPU, so a busy site compresses at a reverse proxy.

TrapNever cache a per-user response under the URL alone.

Connection pools and transactions

Create one pool at startupmysql.createPool({ connectionLimit: 10 })
Run one querypool.execute(sql, params)
Start on one borrowed connectionawait pool.getConnection()await conn.beginTransaction()
Commit, then respondawait conn.commit()
Undo on any error, and releaseawait conn.rollback()conn.release()

TipEach process has its own pool, so 4 workers with a limit of 10 can open 40 connections.

TrapA query on the pool runs outside the transaction. Always release conn, or the pool runs out.

Run it in production

config, logs, health and a clean stop

Environment variables

Load .env with a packagerequire('dotenv').config();
Load .env with no package (20.6+)node --env-file=.env app.js
Make Express cache templatesNODE_ENV=production

TrapEvery value is a string, so convert numbers and booleans.

Health, metrics and profiling

Report whether this instance can serveapp.get('/health', check);200 if it can, 503 if not.
Split liveness and readinessLiveness finds a stuck process. Readiness checks the dependencies.
Time a requestprocess.hrtime.bigint()
See when sync work blocks every requestmonitorEventLoopDelay()
Write a CPU or heap profile to disknode --cpu-prof app.jsnode --heap-prof app.js

Tipprom-client exposes latency histograms and event loop lag. Never bind node --inspect to a public address.

TrapA liveness check on the database restarts every container in an outage.

Time out slow requests

Send 503 when a timer firessetTimeout(send503, 10000);
Clear it when the response endsres.on('close', clear);

Tip503 means the server was slow. 408 means the client was slow to send.

TrapThe timeout doesn't stop the handler. Check res.headersSent, and abort fetch() calls.

Graceful shutdown

Catch the stop signalprocess.on('SIGTERM', stop);
Refuse new connections, finish open onesserver.close(cb);
Force the exit if a WebSocket keeps it opensetTimeout(() => process.exit(1), 10000);

TipFail the readiness check first, and close the pool before process.exit(0).

Clustering across CPU cores

Start one worker per corecluster.fork();
Tell the primary from a workercluster.isPrimaryIt replaced cluster.isMaster.
Count usable coresos.availableParallelism()

TipPM2 does the same with -i max. Clustering also multiplies memory and database connections.

TrapWorkers share no memory. Keep sessions and counters in Redis.

Structure, tests & services

what a larger system adds

Structure a large app

Split the codeBy feature, then by layer.
Give each layer one jobController reads req. Service holds the logic. Repository talks to the database.
Let tests import the appBuild it in app.js, listen in server.js.

TrapKeep req and res out of services, so jobs and tests can call them without HTTP.

Test and document the API

Test a route through HTTPrequest(app).post('/users').expect(201)
Split the testsIntegration per route, unit for services, a few end-to-end.
Build an OpenAPI spec from commentsswaggerJsdoc(options)
Serve Swagger UI at /api-docsswaggerUi.setup(specs)

TipComments can drift from the code. Spec-first means you write openapi.yaml by hand.

TrapUse a separate test database, never a shared one.

Version an API

Put the version in the URLapp.use('/api/v2', v2Router);
Skip to the next route on another versionnext('route')

TipA header keeps one URL per resource, but caches keyed on the URL can't tell versions apart.

TrapPlain next() stays on the same route, so v1 would answer every version.

Add WebSockets

Share one port with HTTPhttp.createServer(app)socketIo(server)server.listen(3000)
Reach clients on other instancesUse a Socket.IO Redis adapter. Long-polling also needs sticky sessions.

TipSocket.IO has its own protocol, so plain WebSocket clients can't connect. For raw WebSockets, use ws.

Trapapp.listen() would create a second server that Socket.IO isn't attached to.

Talk to other services

Pick how to call a serviceHTTP for an answer now. A message broker for work that can wait.
Wrap a call in a circuit breakernew CircuitBreaker(fn, { timeout: 3000 })
Explain open and half-openOpen returns the fallback at once. Half-open lets a trial call through.

TrapRetry only idempotent calls such as GET. A retried POST can run twice.

Express, Fastify or NestJS

Pick ExpressMinimal core, widest middleware choice, full design freedom.
Pick FastifyThroughput and built-in JSON Schema validation.
Pick NestJSOne fixed architecture for a large team, with dependency injection.

TipNestJS runs on Express by default, or on Fastify.

TrapExpress has no built-in validation or structure, so large codebases drift.

Found this useful? Pass it on.
Pro · $10/mo

This sheet stays free. With Pro, you can:

  • Every answer on this sheet. Read the full answer to all 52 questions it links, not just the 7 free ones.
  • My sheet. The cards you star and the ones you miss in Quiz me, from every sheet, on one page to quiz and print.
  • PDFs. Download this sheet and My sheet, laid out for A4, with every question a link to its answer.
  • The rest of Pro. Every answer in the question library, and 1,000 AI credits a month.