All Articles
Backend & API Development

How to Build a REST API with Node.js That Doesn't Break Under Load

Most Node.js APIs work fine in development and fall apart in production. Here is how to build one that holds up when it actually matters.

Velox Studio7 min read

Most Node.js APIs work fine in development. Then they go to production and break in ways nobody expected.

The problem is not Node.js. The problem is how the API was built. Development environments are forgiving. Production is not.

Here is how to build a REST API with Node.js that actually holds up when real users hit it.

Start With a Proper Project Structure

The most common mistake is dumping everything into a single server.js file. It works until it doesn't - and when it breaks, it is painful to debug.

A structure that scales:

/src
  /routes - API route definitions
  /controllers - Request handling logic
  /services - Business logic
  /middleware - Auth, validation, error handling
  /models - Database schemas
  /config - Environment configuration
  server.js - Entry point

Each layer has one responsibility. Routes define endpoints. Controllers handle requests and responses. Services contain business logic. This separation makes your codebase easier to test, debug, and extend.

Handle Errors the Right Way

Most Node.js APIs handle errors reactively - something fails, an error gets thrown, and if you are lucky it gets caught somewhere. Under load, this falls apart quickly.

Build a centralised error handler from the start:

javascript
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    success: false,
    error: message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
};

module.exports = errorHandler;

Register it last in your Express middleware chain. Every unhandled error flows through it, giving you consistent error responses and a single place to log failures.

Also add a catch for unhandled promise rejections at the process level:

javascript
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  process.exit(1);
});

Without this, a failed async operation can silently crash your server.

Validate Every Request

Never trust incoming data. Not from your own frontend, not from trusted clients - never.

Use a validation library like Joi or Zod at the controller level before any business logic runs:

javascript
const Joi = require('joi');

const createUserSchema = Joi.object({
  name: Joi.string().min(2).max(50).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
});

const validateRequest = (schema) => {
  return (req, res, next) => {
    const { error } = schema.validate(req.body);
    if (error) {
      return res.status(400).json({
        success: false,
        error: error.details[0].message
      });
    }
    next();
  };
};

This keeps validation logic out of your controllers and makes it reusable across routes. Bad requests get rejected at the door - they never reach your database.

Add Rate Limiting

Without rate limiting, a single bad actor or a traffic spike can take your API down. The express-rate-limit package handles this cleanly:

javascript
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per window
  message: {
    success: false,
    error: 'Too many requests, please try again later.'
  }
});

app.use('/api/', limiter);

For authentication routes specifically, use a stricter limit - something like 5 requests per 15 minutes. Brute force attacks on login endpoints are extremely common.

Use Async/Await Correctly With Express

Express does not catch errors from async functions automatically. Without a wrapper, an async error will crash your process silently:

javascript
// This will crash without proper handling
app.get('/users', async (req, res) => {
  const users = await User.find(); // if this throws, Express won't catch it
  res.json(users);
});

Wrap async route handlers to catch and forward errors:

javascript
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Now this works safely
app.get('/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json({ success: true, data: users });
}));

This small wrapper eliminates an entire class of silent failures.

Use Environment Variables Properly

Hardcoded credentials in your codebase are a security incident waiting to happen. Use dotenv for local development and proper secrets management in production:

javascript
require('dotenv').config();

const config = {
  port: process.env.PORT || 3000,
  mongoUri: process.env.MONGO_URI,
  jwtSecret: process.env.JWT_SECRET,
  nodeEnv: process.env.NODE_ENV || 'development'
};

const required = ['MONGO_URI', 'JWT_SECRET'];
required.forEach(key => {
  if (!process.env[key]) {
    console.error(`Missing required environment variable: ${key}`);
    process.exit(1);
  }
});

Failing fast on startup is far better than mysterious failures at runtime.

Add Logging

console.log is not a logging strategy. In production, you need structured logs that are searchable and include context:

javascript
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

Structured JSON logs mean you can actually find what went wrong when something fails at 2am.

The Result

A Node.js REST API built this way handles real traffic:

  • Errors are caught and handled centrally - no silent crashes
  • Bad requests are rejected before hitting the database
  • Rate limiting prevents abuse and traffic spikes
  • Async errors are forwarded correctly to the error handler
  • Logs give you visibility into what is happening

The difference between an API that works in development and one that works in production is almost always in the fundamentals, not the features.

At Velox Studio, every backend we build follows these patterns from day one - not as an afterthought.

What is the most common Node.js production issue you have encountered? Drop it in the comments.

Need a Node.js backend built to handle real load?

We build production-grade Node.js APIs with proper error handling, validation, rate limiting, and architecture that holds under traffic.

View Backend Development

Tags

Node.jsREST APIBackend DevelopmentAPI DesignExpress.js

V

Velox Studio

AI-Powered Development Studio

Share

Related Articles

Backend & API Development

REST vs GraphQL: How to Choose for Your Next Web App

Both work. Both ship products to production every day. But they are not interchangeable - the right choice depends on factors most teams ignore until they regret the decision. Here is the honest framework.

11 min readRead Article
Backend & API Development

How to Structure Your Next.js API Routes So They Don't Become a Mess at Scale

Most Next.js projects start with API routes dumped in one folder with no separation of concerns. Here is the four-layer pattern that keeps your API clean, testable, and maintainable as it scales from 5 endpoints to 100+.

13 min readRead Article
Backend & API Development

How to Choose Between MongoDB and PostgreSQL for Your SaaS

The MongoDB vs PostgreSQL debate is older than most production SaaS products. Here is the honest framework we use to pick, and the reasons most teams get the decision backwards.

11 min readRead Article