What a production Express app needs, and who this guide is for

Express describes itself as a fast, unopinionated, minimalist framework, and that is why teams still choose it. The core is a thin routing layer over Node's HTTP server. Almost everything else, such as body parsing, sessions, compression, and security headers, comes from separately installed middleware. You can see what runs on every request and replace any part of it. The cost is that Express makes few production decisions for you. An app that works in development can go live with no consistent error responses, no security headers, and route handlers that mix HTTP parsing with database calls.

In this guide, "production-ready" means making three decisions on purpose:

  • A layered structure. Routing, business logic, and data access live in separate modules, and the app is defined separately from the server that listens on a port. This makes each layer testable on its own.
  • One centralized error path. Every failure, synchronous or asynchronous, reaches a single handler that logs it and returns a consistent, safe response.
  • A small security baseline. Security headers, correct proxy trust, hardened cookies, input limits, and rate limiting are configured once and applied to every request.

These decisions matter more than how many packages you install. A well-structured app with five dependencies is easier to run than a loosely organized one with fifty.

The guide is for backend and platform engineers running Express HTTP APIs and services behind a load balancer, where the team owns its conventions. If you want the framework to enforce structure, or an opinionated framework's conventions are worth more to you than flexibility, choose that framework instead.

Structuring the app: separate app from server, layer by responsibility

Begin by splitting one file into two modules. The first, conventionally app.js, creates the Express instance, registers middleware and routers, and exports the configured app. It never calls listen. The second, server.js, imports the app, creates the HTTP server, binds the port, and handles process signals. With this split, integration tests can pass the app straight to an in-process HTTP client without claiming a port. A worker or CLI script can reuse the same configuration. Shutdown logic also lives in one place, because the module that owns the server is the only one that can close it.

Inside the app, give each layer one job:

  • Routers deal with HTTP and nothing else. They read params, headers and body, call a service, and pick the status code and response shape.
  • Validation runs at the router boundary, either as middleware or as the first step in a handler. It rejects malformed input before any business code sees it.
  • Services contain the business logic. They take plain, already-validated values, not req or res, so they can be tested without Express and called from places other than HTTP.
  • Data-access modules are the only code that talks to the database, cache or external stores. Swapping a driver or adding a query stays local to them.

The trade-off is that Express enforces none of this. Nothing stops a handler from running SQL or a service from reading req.headers. The layering is a team convention, and it erodes unless lint rules restrict imports between directories and code review catches violations.

// app.js: builds and exports the configured app. It never binds a port.
const express = require('express');
const usersRouter = require('./routes/users');

const app = express();
app.use(express.json({ limit: '100kb' }));
app.use('/users', usersRouter);

module.exports = app;

// server.js: owns the HTTP server, the port and process signals.
const http = require('http');
const app = require('./app');

const port = Number(process.env.PORT) || 3000;
const server = http.createServer(app);

server.listen(port, () => {
  console.log(`listening on ${port}`);
});

// Only this module holds a server reference, so shutdown is handled here.
function shutdown(signal) {
  console.log(`${signal} received, closing server`);
  // Stop accepting new connections and exit after in-flight requests finish.
  server.close((err) => {
    process.exit(err ? 1 : 0);
  });
  // Force exit if connections do not drain within a deadline.
  setTimeout(() => process.exit(1), 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Two modules: app.js configures and exports the Express app without listening, and server.js creates the HTTP server, binds the port and closes it on SIGTERM or SIGINT, so tests and workers can import the app without opening a port.

How middleware order works

Express keeps one ordered list of middleware and routes per app or router. Each request walks that list from the top until something sends a response. Registration order is execution order, so the sequence below is a design decision:

  1. Request ID and logging. These go first so that every request gets an ID and a log line, including requests rejected later with a 413 from the body parser or a 404. If you move them below the routes, failed requests stop showing up in the logs, and those are usually the ones you need to trace.
  2. Security headers (helmet). Helmet sets headers on res before any handler writes the response. If you register it after the routes, every response that a route already sent goes out without those headers.
  3. Body parsing with explicit limits. Set the limit directly, for example express.json({ limit: '100kb' }), and choose a value that fits your largest legitimate payload. If the parser runs after the routes, req.body is undefined in your handlers. If you mount it globally without a limit, you are relying on a default you never reviewed.
  4. Routes.
  5. 404 handler. This is a plain middleware that runs only when no route matched. If you place it above the routes, it answers every request with a 404.
  6. Error handler. It takes four arguments, (err, req, res, next), and goes last. Express only passes an error to error handlers registered after the point where the error occurred, so an error handler placed earlier never sees errors from your routes.

Every entry in this list runs on every request that reaches it, and every package you add is a dependency you have to track for security releases. A short stack keeps per-request work down and leaves fewer packages to audit and patch.

const express = require('express');
const helmet = require('helmet');
const { randomUUID } = require('node:crypto');

const app = express();

// 1. Request ID and logging first, so 404s and 413s are logged too.
app.use((req, res, next) => {
  req.id = req.get('x-request-id') || randomUUID();
  res.set('x-request-id', req.id);
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.id} ${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
  });
  next();
});

// 2. Security headers before any handler can send a response.
app.use(helmet());

// 3. Body parsing with an explicit limit sized to the largest real payload.
app.use(express.json({ limit: '100kb' }));

// 4. Routes.
app.post('/orders', (req, res) => {
  res.status(201).json({ received: req.body });
});

// 5. 404: runs only when no route above matched.
app.use((req, res) => {
  res.status(404).json({ error: 'Not found', requestId: req.id });
});

// 6. Error handler last: four arguments, sees errors from everything above.
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err); // let Express close the connection
  const status = err.status || err.statusCode || 500;
  res.status(status).json({ error: status === 500 ? 'Internal error' : err.message, requestId: req.id });
});

app.listen(3000);

A minimal Express app that registers request ID and logging, helmet, a size-limited JSON parser, routes, a 404 handler and a four-argument error handler in the recommended order.

Centralized error handling and async errors in Express 4 vs 5

Express recognizes an error handler by its four-argument signature (err, req, res, next). If you drop next because you don't use it, Express treats the function as ordinary middleware. Register one handler after all routes. It should map known error types to status codes: validation failures to 400, missing resources to 404, conflicts to 409, and anything unrecognized to 500. Every error response should use the same shape, such as { error: { code, message, requestId } }, so clients can parse failures the same way they parse successes. On the server, log the full error, the stack and the request ID together. Send the client only the code, message and request ID, never the stack trace.

When you catch an error and rethrow it with more context, pass the original as cause (new Error(message, { cause: err })). This keeps the underlying stack available to the logger instead of replacing it.

How errors from async handlers reach this handler depends on the major version:

Behavior Express 4 Express 5
Rejected promise in a route handler Not forwarded. Catch it and call next(err), or use a wrapper Forwarded to the error handler automatically
Synchronous throw Forwarded Forwarded
Path syntax Older matching rules Stricter rules: wildcards need names, and optional segments use braces

Because the path syntax changes, an upgrade can break routes without any error at startup. Route-level tests should run before you upgrade.

Treat operational errors (bad input, a timed-out dependency) differently from programmer errors (a TypeError or a broken invariant). Operational errors become responses. Programmer errors mean the process is in an unknown state. On uncaughtException or unhandledRejection, log the error and exit, and let the supervisor start a clean process. With several instances behind the load balancer, an exit drops only the requests that one process was handling.

const express = require('express');
const db = require('./db'); // placeholder data-access module
const app = express();

class AppError extends Error {
  constructor(message, { status = 500, code = 'INTERNAL', cause } = {}) {
    super(message, { cause }); // keep the original error and its stack
    this.status = status;
    this.code = code;
  }
}

// Express 4 only: send rejected promises to next(). Express 5 does this itself.
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await db.findOrder(req.params.id).catch((err) => {
    throw new AppError('Order lookup failed', { status: 503, code: 'DEPENDENCY_UNAVAILABLE', cause: err });
  });
  if (!order) throw new AppError('Order not found', { status: 404, code: 'NOT_FOUND' });
  res.json(order);
}));

// Register after all routes. Express needs all four parameters to treat this as an error handler.
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err); // the response has started, so let Express close the connection
  const known = err instanceof AppError;
  // The server log gets the full detail: stack, cause chain and request ID (set by earlier middleware).
  console.error({ requestId: req.id, err });
  res.status(known ? err.status : 500).json({ error: {
    code: known ? err.code : 'INTERNAL',
    message: known ? err.message : 'Internal server error', // never send the stack to the client
    requestId: req.id,
  } });
});

// Programmer errors leave the process in an unknown state. Log, then exit so the supervisor starts a clean one.
for (const evt of ['uncaughtException', 'unhandledRejection']) {
  process.on(evt, (err) => { console.error({ evt, err }); process.exit(1); });
}

A single four-argument error handler maps AppError instances to status codes and a consistent error shape, keeps the original error as cause in server logs, uses an Express 4 async wrapper that Express 5 no longer needs, and exits the process on uncaught exceptions and unhandled rejections.

Security basics: helmet, trust proxy, cookies, input and rate limits

Helmet is the baseline for security headers. Its defaults set a Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options, Referrer-Policy and the cross-origin isolation headers. They also remove X-Powered-By. Call app.disable('x-powered-by') as well so the header stays off if Helmet is reconfigured later. The default CSP is strict, so it can block inline scripts, third-party CDNs, analytics and embedded iframes. When the frontend breaks, add the specific sources it actually loads to the allowlist rather than disabling CSP, which removes one of the strongest defenses against XSS.

Cap request body size on express.json() and express.urlencoded() through their limit option, so a single large payload cannot tie up memory. Validate every input against a schema at the route boundary, reject unknown fields, and encode output for its context. For session cookies, set httpOnly so scripts cannot read them, secure so they are only sent over HTTPS, and sameSite (usually lax) to reduce CSRF exposure.

Behind a load balancer, the trust proxy setting decides what req.ip returns:

Setting Effect on req.ip Risk
true (trust all) Leftmost X-Forwarded-For value Clients can spoof their IP, which defeats rate limits and audit logs
Unset The load balancer's address Every request appears to come from one IP
Hop count or proxy subnets The real client IP Must be updated when the network topology changes

Apply rate limits to login, password reset and other expensive endpoints. A limiter that keeps counts in process memory only sees the traffic for its own instance, so the effective limit grows with the number of instances. A shared store makes the limit global, but it adds a dependency and a network round trip to every limited request.

const express = require('express');
const helmet = require('helmet');
const { rateLimit } = require('express-rate-limit');

const app = express();

// Exactly one load balancer sits in front of the app. Trust one hop so req.ip
// is the real client, not the LB, and so clients cannot spoof it via X-Forwarded-For.
app.set('trust proxy', 1);

// Keep the header off even if Helmet is reconfigured later.
app.disable('x-powered-by');

// Keep Helmet's default CSP and allowlist only the sources the frontend loads.
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      scriptSrc: ["'self'", ''],
      imgSrc: ["'self'", 'data:', ''],
    },
  },
}));

// Cap body size so one large payload cannot tie up memory.
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '100kb' }));

// In-memory store: each instance counts separately, so N instances allow N x limit.
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });

app.post('/login', loginLimiter, (req, res) => {
  // ...validate req.body against a schema and verify credentials...
  res.cookie('sid', 'placeholder-session-id', {
    httpOnly: true,   // not readable from page scripts
    secure: true,     // sent over HTTPS only
    sameSite: 'lax',  // reduces CSRF exposure
  });
  res.sendStatus(204);
});

app.listen(3000);

An Express setup that trusts a single proxy hop, keeps Helmet's CSP with an explicit allowlist, caps body sizes, rate-limits login and sets hardened session cookie flags.

Operating it: NODE_ENV, health checks, graceful shutdown, logs and patching

Set NODE_ENV=production in the environment that starts the process, not in application code. Express and many libraries read it at load time: view templates are cached, and the default error handler stops sending stack traces to clients. Running without it makes the app slower and leaks internal details.

Health and readiness

Expose two endpoints. A liveness check answers "is the process able to respond?" It should do no I/O, so that a slow database does not get healthy instances restarted. A readiness check answers "should this instance receive traffic?" It reports failure during startup, when a critical dependency is unreachable, and during shutdown. Keep both outside authentication and rate limiting so the orchestrator can always reach them.

Graceful shutdown

On SIGTERM, first flip the readiness flag so the load balancer stops sending traffic. Then call server.close(), which stops accepting new connections while in-flight requests finish. Idle keep-alive connections can keep the server open, so close them explicitly. Close database pools last, and set a hard timeout that exits the process if draining takes longer than the orchestrator's grace period.

Logs

Write one structured JSON line per event to stdout, and include the request ID on every line. That ID is what lets you join a 500 response, its stack trace and its downstream calls in your log store.

Patching

Schedule npm audit and npm outdated as recurring work, not as a reaction to incidents. Vulnerabilities often ship in middleware and transitive dependencies rather than in Express itself. The Express site has announced coordinated security releases covering packages such as cookies, morgan, multer and proxy-addr. Upgrading Express alone would not have pulled in those fixes.

Alternatives and impact on cost and engineering time

That dependency surface is the main thing to weigh when you choose between a hand-assembled Express baseline and the alternatives. The baseline is small, and every middleware in it is a deliberate choice, so audits and upgrades cover a short list that the team already understands. The trade-off is that structure and validation depend on discipline, because nothing in the framework enforces them.

Approach Structure and validation Audit and upgrade burden Main cost
Hand-assembled Express By convention only Low, because the dependency list is short and known Drift between services
Opinionated Node.js framework Enforced through modules, decorators and schemas Higher, because there is more framework code to track Learning curve and harder escape hatches
Shared internal Express template Enforced by copying a reviewed baseline Low, but someone must maintain the template Template ownership and propagating updates

Some savings compound over time. A consistent error shape combined with request IDs lets an engineer go from a client report to the exact log lines in one search. Separating the app from the server means tests can drive the app in-process without binding ports, so the test suite stays fast enough to run on every commit.

Some costs are paid up front. Tightening CSP beyond the defaults takes iteration against real pages. Moving from Express 4 to 5 needs regression tests around routing and async errors. Running multiple instances requires a shared store for rate-limit state.

Illustrative estimate (assumed numbers, not a benchmark):

Hours saved per year = incidents per year × minutes saved per incident ÷ 60

Assumptions:

  • 40 incidents per year
  • 30 minutes saved per incident by request-ID correlation

40 × 30 ÷ 60 = 20 hours per year. Compare that figure with your own estimate of the one-time effort to add request IDs and a consistent error shape.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to expressjs production, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.