Building Scalable APIs with Node.js: Lessons from Production
Practical patterns for building Node.js APIs that handle real load — error handling, validation, observability, and the architectural decisions that matter.
I've shipped Node.js APIs that have survived traffic spikes, schema migrations, and three product pivots. Here's what actually held up.
Validate at the edge#
Every API endpoint should validate its input before doing anything else. Use Zod, not hand-rolled checks:
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
})
app.post('/users', async (req, res) => {
const data = CreateUserSchema.parse(req.body)
const user = await db.user.create({ data })
res.json(user)
})This catches malformed input before it reaches your business logic. Your TypeScript types are inferred from the schema — one source of truth.
Errors are part of the API#
Don't return 500: Internal Server Error for expected failures. Define your error shapes:
class APIError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message)
}
}
// In a handler:
if (!user) throw new APIError(404, 'USER_NOT_FOUND', 'No user with that ID')Then a single error middleware translates exceptions into clean JSON responses. Clients can branch on code, not parse strings.
Observability isn't optional#
When something breaks at 3am, logs are all you have. Three things to log on every request:
- A request ID (generate or accept from the client)
- The user ID, if authenticated
- The route, status, and duration
app.use((req, res, next) => {
const id = req.headers['x-request-id'] ?? crypto.randomUUID()
const start = Date.now()
res.on('finish', () => {
logger.info({
id, route: req.path, status: res.statusCode,
ms: Date.now() - start, userId: req.user?.id,
})
})
next()
})Pipe this to a structured log aggregator (Datadog, Axiom, or even just pino with a file). Future-you will be grateful.
Never log full request bodies in production. PII, passwords, and tokens can leak. Log a hash or the keys only.
Database connections will betray you#
The default Node.js + Postgres setup will run out of connections under load. Use a pool, set sensible limits, and respect them:
import { Pool } from 'pg'
const pool = new Pool({
max: 20, // adjust to your DB's max_connections
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000,
})If you're on serverless, use a connection pooler like PgBouncer or Supabase's pooler. Cold starts will hammer your DB otherwise.
Don't reach for microservices#
Most APIs do not need to be split across services. A well-organized monolith with clear modules will outperform a poorly-organized microservice architecture every time. Wait until you have specific scaling pain before splitting.
That's the production playbook. Validate input, define errors, log everything that matters, manage connections, and resist premature distribution. The rest is just code.
Subscribe to the newsletter
Get an email whenever I publish a new post. No spam, unsubscribe anytime.
Comments
Share your thoughts. Your email is private and won't be displayed.
Loading comments…
Continue reading
Building Voice AI Agents with Twilio, OpenAI, and FastAPI
Lessons from production: how to architect a real-time voice agent that handles inbound and outbound calls, logs conversations live, and scales reliably.
ReadGetting Started with Next.js 14: A Practical Guide
A hands-on walkthrough of the App Router, Server Components, and Partial Prerendering — the features that make Next.js 14 a serious upgrade.
ReadMastering Tailwind CSS: From Basics to Beautiful
Advanced Tailwind techniques to build responsive, maintainable designs faster — without the CSS bloat.
Read