cheat sheet

express

Package-level reference for Express on npm — install, middleware model, Node support, 4-to-5 migration, and production deployment.

express

What it is

express is the original minimalist Node.js web framework — a thin wrapper over Node's http module that adds routing, a middleware pipeline, and a request/response API with view-engine hooks. It is intentionally unopinionated: validation, ORMs, logging, and templating are all add-ons rather than core concerns.

Reach for express when you need the largest middleware ecosystem in JavaScript, when a colleague will read the code without learning a new framework, or when you're maintaining one of the millions of legacy Express apps already in production. Choose fastify or hono for new high-throughput work; choose nestjs for opinionated DI-heavy services.

Install

Express is a library — it lives in your application's dependencies and runs as a long-lived Node process.

bash
npm install express

Output: added express to dependencies

bash
pnpm add express

Output: added 1 package, linked from store

bash
yarn add express

Output: added express

bash
bun add express

Output: installed express

For TypeScript projects, install the type package as a dev dependency — Express does not ship types itself.

bash
npm install --save-dev @types/express

Output: added @types/express to devDependencies

Versioning & Node support

The current line is express@5.x (released 2024 after years of preview); express@4.x remains the default of most existing tutorials.

  • express@5 — Node 18+. Async middleware errors propagate to the error handler without next(err). Promise rejections in handlers are caught automatically. Several deprecated APIs (res.sendfile, req.param, app.del) were removed.
  • express@4 — Node 6+. The dominant version on npm by download count. Still maintained for security patches.
  • express@3 — historical. Do not start new work here.

Express follows semver loosely — many minor releases include type-definition or middleware-default tweaks. Pin minor in production ("express": "5.x") and run npm outdated quarterly.

Package metadata

  • Maintainer: OpenJS Foundation / Express TC (community-led since 2014)
  • Project home: github.com/expressjs/express
  • Docs: expressjs.com
  • npm: npmjs.com/package/express
  • License: MIT
  • First released: 2010
  • Downloads: ~30 million+ weekly — still the most-downloaded Node web framework by an order of magnitude.

Peer dependencies & extras

Express has zero peer dependencies. Middleware is published as separate npm packages.

Commonly-paired middleware:

  • cors — CORS handling for cross-origin requests
  • helmet — secure HTTP headers
  • morgan — request logging
  • compression — gzip / brotli compression
  • cookie-parser — cookie parsing
  • express-session — server-side sessions
  • express-rate-limit — rate limiting
  • multer — multipart/form-data uploads
  • express-validator — schema validation
  • passport — authentication strategies

Express 4.16+ bundles body-parser internally — use express.json() and express.urlencoded() instead of the separate package. See the dedicated body-parser article for the standalone use case.

Alternatives

FrameworkTrade-off
fastifySchema-driven validation, 2-3× faster, first-class types. Smaller ecosystem.
honoEdge-first; runs on Workers / Deno / Bun. Web-standard Request/Response.
koaExpress creators' rewrite. Async-native, smaller core, weaker ecosystem.
nestjsClass + decorator + DI architecture. Heavier, opinionated, TypeScript-first.
h3Powers Nuxt / Nitro. Edge-friendly, composable utilities.
restifyOlder REST-focused framework. Largely superseded.

Real-world recipes

Minimal HTTP app

The smallest useful Express app — bind, serve, listen. Demonstrates the core app.get / res.send pattern.

javascript
import express from "express";

const app = express();
app.get("/", (req, res) => res.send("ok"));

app.listen(3000, () => console.log("http://localhost:3000"));

Output: curl localhost:3000 returns ok; server logs the listen URL.

Route + middleware chain

Middleware is just a function with (req, res, next). Mount with app.use (global) or pass as additional args to a route (per-route). Order matters — middleware runs in registration order until one ends the response.

javascript
import express from "express";

const app = express();

const requireAuth = (req, res, next) => {
  if (req.headers.authorization !== "Bearer secret") {
    return res.status(401).json({ error: "unauthorized" });
  }
  next();
};

app.get("/public", (req, res) => res.json({ ok: true }));
app.get("/private", requireAuth, (req, res) => res.json({ user: "alice" }));

app.listen(3000);

Output: /public returns 200 unconditionally; /private returns 401 without the bearer header, 200 with it.

JSON body + validation

express.json() parses application/json bodies in-place. Pair with express-validator (or zod) for schema validation. A request without a matching schema should fail before touching the handler.

javascript
import express from "express";
import { body, validationResult } from "express-validator";

const app = express();
app.use(express.json({ limit: "100kb" }));

app.post(
  "/users",
  body("email").isEmail(),
  body("age").optional().isInt({ min: 0 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
    res.status(201).json({ id: crypto.randomUUID(), email: req.body.email });
  }
);

app.listen(3000);

Output: POST /users with {"email":"alice@example.com"} returns 201; missing email returns 400 with structured errors.

Centralised error handler

Express recognises error-handling middleware by arity — four parameters. Place it last; uncaught throws (in Express 5) or next(err) calls (in Express 4) land here.

javascript
import express from "express";

const app = express();

app.get("/boom", () => {
  throw new Error("kaboom");
});

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

app.listen(3000);

Output: /boom returns 500 with {"error":"internal_error",...}; stack trace logged server-side.

Static files

express.static serves files from a directory with sensible defaults — ETag, last-modified, max-age. For production, prefer a CDN or reverse proxy, but express.static is fine for small apps.

javascript
import express from "express";
import path from "node:path";

const app = express();
app.use("/assets", express.static(path.join(process.cwd(), "public"), {
  maxAge: "1d",
  immutable: false,
}));

app.listen(3000);

Output: GET /assets/logo.svg returns the file with Cache-Control: max-age=86400.

Production deployment

Express is a long-lived Node process. Treat it like any production service — process supervision, clustering, health checks, log shipping.

Process supervisor: PM2

bash
npm install -g pm2
pm2 start dist/server.js -i max --name api
pm2 startup
pm2 save

Output: runs N workers (one per CPU core), auto-restarts on crash, persists across reboots.

-i max enables Node cluster mode — multiple workers share the listening socket via the OS load balancer. For CPU-bound work this scales linearly with cores.

Behind a reverse proxy

Run Express behind Nginx / Caddy / a cloud load balancer for TLS, HTTP/2, and static-asset caching. Enable trust proxy so Express honours X-Forwarded-* headers.

javascript
app.set("trust proxy", 1); // trust first proxy hop

Output: req.ip resolves to the real client IP rather than the proxy's loopback.

Docker

dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]

Bind to 0.0.0.0 inside containers — app.listen(3000, "0.0.0.0", ...) — the default 127.0.0.1 is unreachable from outside the container.

Health checks

javascript
app.get("/healthz", (req, res) => res.json({ ok: true }));

Output: load balancers can poll /healthz without flooding application logs.

Gate noisy access logs at the health-check path via morgan's skip option.

Performance tuning

Express is slower than Fastify or Hono by design — the middleware model has per-call overhead and there's no schema-driven serialization. Most apps are network-bound and never notice.

  • Set NODE_ENV=production. Express disables view caching and verbose error pages otherwise. ~2× throughput improvement on hot paths.
  • Use compression() selectively. gzip costs CPU; only enable for large responses. Static assets compressed at the proxy beat in-process compression.
  • Reuse JSON serialization. Avoid building large response objects per request. Cache where the data permits.
  • Cluster across cores. Single-threaded Node leaves CPU on the table. PM2 or node:cluster is mandatory above a few hundred req/s.
  • Avoid synchronous middleware. Anything calling fs.readFileSync or bcrypt.hashSync blocks the event loop. Use the async siblings.
  • Profile with clinic.js. npx clinic doctor -- node server.js produces a diagnostic of event-loop lag, GC, and CPU hot spots.

Version migration guide

The big migration is express@4express@5. Most of the breaking changes are pruning, not rewrites.

4 → 5 highlights:

  1. Promise rejections propagate to the error handler automatically. Drop the next(err) boilerplate in async handlers.
  2. Removed APIs: res.sendfile (use res.sendFile), req.param (use req.params / req.query), app.del (use app.delete), res.json(status, body) (use res.status(s).json(b)).
  3. path-to-regexp upgraded. Some route patterns may match differently — /:foo* no longer matches greedily; convert to /{:foo}+ or split routes.
  4. Body parsers stricter. express.json() rejects on syntax errors by default rather than treating them as empty bodies.
  5. req.body undefined without parser middleware. Removed implicit fallback to {}.
  6. router.use no longer accepts trailing slashes in mount paths. Normalize the mount path.

Migration sequence:

bash
npm install express@5
npx eslint --no-eslintrc --rule '{"deprecation/deprecation":"error"}' src/
npm test

Output: ESLint flags removed APIs; fix until clean; test suite catches behavioural drift.

For most apps the migration is mechanical — a few hours of grep and test.

Security considerations

  • Always helmet(). Sets sane defaults for CSP, HSTS, X-Content-Type-Options, X-Frame-Options. One line: app.use(helmet()).
  • Body-parser limits. Default JSON body limit is 100 KB; raise only when you must, and validate uploads pass through. Unbounded sizes invite memory-exhaustion DoS.
  • Cookie security. httpOnly: true and secure: true on every session cookie. sameSite: "lax" minimum.
  • trust proxy carefully. true trusts every hop, which spoofs IPs if you're not behind a single trusted proxy. Set the exact hop count (1, 2) instead.
  • Don't leak stack traces. Default error handler emits the stack in development; ensure production returns a sanitized envelope.
  • Rate-limit /login, /signup, /reset-password. express-rate-limit with stricter window/limit on auth routes blocks credential stuffing.
  • req.query parsing. Express 5 parses ?a[b]=c as a nested object — easy to over-trust. Cast to strings before passing to ORMs.
  • CSRF tokens. Cookie-based session apps still need CSRF protection. Use csurf or move to short-lived JWT in Authorization.
  • npm audit weekly. Express's transitive deps (path-to-regexp, qs) have had CVEs; audit and patch promptly.

Testing & CI integration

Express's testing story is dominated by supertest — wraps the app, makes assertions on responses without binding a port.

javascript
import { test, expect } from "vitest";
import request from "supertest";
import express from "express";

const app = express();
app.get("/", (req, res) => res.json({ ok: true }));

test("GET / returns ok", async () => {
  const res = await request(app).get("/");
  expect(res.status).toBe(200);
  expect(res.body).toEqual({ ok: true });
});

Output: 1 passed. Tests run in microseconds; no real TCP socket; concurrent tests don't conflict on ports.

Pair with vitest or jest. CI workflow:

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npm test

Ecosystem integrations

ToolRole
passportPluggable auth strategies — local, OAuth, SAML, OpenID.
socket.ioWebSockets layered over an Express HTTP server.
next.jsCustom server mode can mount Next inside Express, though deprecated in favour of standalone.
swagger-ui-expressServe Swagger UI for OpenAPI documents.
connect-redisRedis-backed session store for express-session.
multerMultipart upload handler.
graphql-http / apollo-server-expressGraphQL servers on Express.
express-openapi-validatorValidate requests against an OpenAPI spec.

Troubleshooting common errors

TypeError: app.use() requires a middleware function — typo in import, or module.exports returns a non-function. Most often: app.use(myMiddleware) where myMiddleware resolved to undefined.

Cannot set headers after they are sent — calling res.send / res.json twice. Most often: forgetting return in an early-exit branch.

PayloadTooLargeError — body exceeded express.json({ limit }). Raise the limit if legitimate, or reject upstream.

Error: Failed to lookup view — using res.render without configuring a view engine. Either set app.set("view engine", "ejs") (or your engine of choice) or switch to res.json.

req.body is undefinedexpress.json() not mounted before the route. Add app.use(express.json()) near the top.

EADDRINUSE — port already taken. lsof -i :3000 to find and kill, or pass PORT=3001 via env.

MaxListenersExceededWarning — too many event listeners on a single emitter. Often process.on("uncaughtException", ...) added in a request handler. Move to top-level.

404 on a route that should exist — middleware before it didn't call next(). Trace through app.use registrations.

When NOT to use this

  • Edge runtimes. Express expects Node's http module. For Cloudflare Workers / Vercel Edge / Deno Deploy use hono or h3 — they share Express's middleware idiom over Web Request/Response.
  • Bun-only deployments. Bun has Bun.serve, which beats Express by 5-10× on identical handlers. Use Bun's native API if you commit to Bun.
  • High-throughput APIs (>20k req/s per core). Fastify's schema-driven serializer doubles throughput. The migration cost amortizes quickly.
  • You want first-class TypeScript. Express types are community-maintained and historically loose. Fastify / Hono / Nest are TS-first.
  • You need built-in DI / module decomposition. Express is functional; for class + DI architecture, NestJS (which can run atop Express) gives you the structure for free.
  • GraphQL-only backends. Apollo and mercurius (Fastify) are GraphQL-native. Express is fine but adds nothing.

See also