Hanzla
Back to blogBackend

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.

3 min read

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:

  1. A request ID (generate or accept from the client)
  2. The user ID, if authenticated
  3. 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.

Warning

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.

Share:

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…