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
Your VoIP Server Won't Get Hacked Overnight. It'll Get Scanned in Minutes.
I put a SIP trunk live at night and woke up unable to make a single call. Nobody hacked me. Here’s what actually happened, how fail2ban saved me, and why it was never the real fix.
ReadVibe Coding Won’t Replace Engineers But It Might Expose Weak Foundations
Vibe coding is being sold as the future of software. Here's why it's a superpower for non-engineers, a trap for students, and why fundamentals still decide who actually makes it.
ReadBuilding 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.
Read