Building Scalable Node.js REST APIs: Architecture and Production Patterns
Introduction
Node.js excels at I/O-bound APIs—JSON over HTTP, real-time adjacency, and rapid iteration. Scaling to production requires more than routing and JSON.stringify: you need clear layering, input validation, consistent errors, observability, security defaults, and deployment patterns that survive traffic spikes.
This guide walks through a reference architecture for REST APIs using Express (patterns apply to Fastify and Hono), covering structure, middleware, database access, testing hooks, and operational concerns.
Table of Contents
- Project Structure and Layering
- Routing and Controllers
- Validation and DTOs
- Error Handling Middleware
- Database and Connection Pooling
- Security and Rate Limiting
- Observability and Health Checks
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
Project Structure and Layering
Separate HTTP concerns from business logic:
src/
routes/ # HTTP route definitions
controllers/ # Parse request, call services, send response
services/ # Business rules
repositories/ # Data access
middleware/ # Auth, validation, logging
lib/ # Shared utilities
app.ts # Express app setup
server.ts # Bootstrap, graceful shutdown
Controllers stay thin:
export async function getUserHandler(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.getById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
} catch (err) {
next(err);
}
}
Routing and Controllers
import { Router } from "express";
import { getUserHandler, updateUserHandler } from "../controllers/user.controller";
import { authenticate } from "../middleware/auth";
import { validate } from "../middleware/validate";
import { updateUserSchema } from "../schemas/user.schema";
const router = Router();
router.get("/users/:id", authenticate, getUserHandler);
router.patch("/users/:id", authenticate, validate(updateUserSchema), updateUserHandler);
export default router;
Version APIs (/v1/users) when breaking changes are expected. Document with OpenAPI.
Validation and DTOs
Never trust client input. Use Zod or Joi:
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["user", "admin"]).default("user"),
});
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.flatten(),
});
}
req.body = result.data;
next();
};
}
Return DTOs—don't leak internal fields like passwordHash.
Error Handling Middleware
Centralize error formatting:
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
}
}
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
Async errors must reach middleware—use express-async-errors or wrap handlers.
Database and Connection Pooling
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
export async function findUserById(id: string) {
const { rows } = await pool.query(
"SELECT id, email, name FROM users WHERE id = $1",
[id]
);
return rows[0] ?? null;
}
In serverless, prefer connection poolers (PgBouncer, Neon). For ORMs, configure pool size per instance count to avoid exhausting DB connections.
Security and Rate Limiting
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
Authenticate with JWT or session middleware early. Apply authorization in services, not only routes.
Observability and Health Checks
import pino from "pino";
import pinoHttp from "pino-http";
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
app.use(pinoHttp({ logger }));
app.get("/health", async (_req, res) => {
const dbOk = await checkDatabase();
res.status(dbOk ? 200 : 503).json({
status: dbOk ? "ok" : "degraded",
uptime: process.uptime(),
});
});
Add request IDs, structured logs, and metrics (Prometheus/OpenTelemetry). Graceful shutdown:
process.on("SIGTERM", async () => {
server.close();
await pool.end();
process.exit(0);
});
Best Practices
- Layer responsibilities—routes → controllers → services → repositories.
- Validate at the boundary—query, body, params.
- Use pagination—
limit,cursororoffsetwith caps. - Idempotent mutations where appropriate—
Idempotency-Keyheader. - Horizontal scale—stateless instances behind load balancer; sticky sessions only if needed.
- Load test before launch—k6, Artillery, or Locust.
Common Mistakes
- Fat controllers with SQL and business rules mixed.
- Unhandled promise rejections in async route handlers.
- Leaking stack traces to clients in production.
- No connection limits—DB meltdown under load.
- Synchronous CPU work on main thread—blocks all requests.
- Missing health checks—orchestrators can't detect bad instances.
Interview Questions
- How do you structure a large Express application?
- Difference between middleware and route handler?
- How to handle errors in async Express handlers?
- Strategies for API versioning?
- How does Node.js scale horizontally?
- Connection pooling challenges in serverless?
- REST vs GraphQL for your API—tradeoffs?
- How to implement graceful shutdown?
API Documentation and Contract Testing
Publish OpenAPI specs generated from Zod schemas:
import { createDocument } from "zod-openapi";
const openApiDoc = createDocument({
openapi: "3.1.0",
info: { title: "User API", version: "1.0.0" },
paths: { /* from schemas */ },
});
Run contract tests in CI—consumer-driven or Dredd against staging—to catch breaking response shape changes before deploy.
Version breaking changes behind /v2 paths; sunset /v1 with documented timeline and Sunset HTTP header.
Rate Limiting and Idempotency
Protect write endpoints:
app.use("/api/", rateLimit({ windowMs: 60000, max: 100 }));
app.post("/api/payments", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (idempotencyKey) {
const cached = await redis.get(`idem:${idempotencyKey}`);
if (cached) return res.status(200).json(JSON.parse(cached));
}
const result = await processPayment(req.body);
if (idempotencyKey) {
await redis.setex(`idem:${idempotencyKey}`, 86400, JSON.stringify(result));
}
res.json(result);
});
Idempotency keys prevent duplicate charges when clients retry on network failure.
Horizontal Scaling Checklist
- Stateless app instances behind load balancer
- Shared session store or JWT if multi-instance
- Database connection pool sized for
instances × poolSize - Health checks that verify DB connectivity
- Graceful shutdown on SIGTERM
- Centralized logging with request correlation IDs
OpenTelemetry Integration
Instrument requests for distributed tracing:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
Traces show database slow queries and external API latency—optimization targets emerge from data, not guesses.
Export traces to Jaeger, Honeycomb, or Datadog; correlate trace_id in logs via middleware.
API Gateway and BFF Layer
At scale, place API behind gateway (Kong, AWS API Gateway) for:
- TLS termination
- Rate limiting
- Request size limits
- WAF rules
BFF aggregates microservices for specific clients—reduces chattiness without exposing internal service topology to browsers.
Schedule regular dependency and security updates—outdated frameworks become performance and compliance liabilities. Automate renovate PRs but require integration test pass before merge; stale APIs accumulate CVEs faster than security scanners alone can prioritize without owner context from the team that ships the service daily.
Publish internal API changelog for every breaking response change—even with versioning, mobile clients lag weeks behind server deploys and deserve advance notice through documented deprecation timelines, sunset headers, and migration guides that partner engineering teams can execute without emergency weekend releases driven by silent contract breaks discovered only after production traffic patterns shift unexpectedly when legacy app versions remain popular in regions with slower update adoption curves across mobile carrier distribution networks worldwide.
Summary
Scalable Node.js REST APIs combine clear architecture, strict validation, centralized errors, and operational hygiene. Layer your code, pool database connections, secure defaults with helmet and rate limits, and instrument everything. Scale out stateless workers and fix bottlenecks with profiling—not premature microservices.