Building Scalable APIs with Node.js: Best Practices
Building an API is easy. Building one that scales to thousands of concurrent users while remaining maintainable — that's the challenge. Here are the patterns that work in production.
Project Structure
A scalable API starts with proper organization:
src/
├── config/ # Configuration and environment
├── controllers/ # Request handlers
├── middleware/ # Auth, validation, error handling
├── models/ # Database models
├── routes/ # Route definitions
├── services/ # Business logic
├── utils/ # Helper functions
└── app.js # App entry point1. Separate Routes, Controllers, and Services
javascript
// routes/users.js
router.get("/users", userController.getAll);
router.post("/users", validate(userSchema), userController.create);
// controllers/userController.js
async function getAll(req, res, next) {
try {
const users = await userService.findAll(req.query);
res.json({ data: users });
} catch (error) {
next(error);
}
}
// services/userService.js
async function findAll(filters) {
return User.find(filters).select("-password").lean();
}2. Centralized Error Handling
javascript
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
const status = err.statusCode || 500;
const message = err.isOperational ? err.message : "Internal server error";
console.error(`[${status}] ${err.message}`, {
path: req.path,
method: req.method,
stack: err.stack,
});
res.status(status).json({
error: { message, status },
});
}3. Input Validation
Never trust client input:
javascript
import Joi from "joi";
const userSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).max(120),
});
function validate(schema) {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: { message: error.details[0].message },
});
}
next();
};
}4. Rate Limiting and Security
javascript
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import cors from "cors";
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: "Too many requests" },
}));5. Database Connection Pooling
javascript
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});6. Caching Strategy
javascript
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function getCachedData(key, fetchFn, ttl = 3600) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const fresh = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(fresh));
return fresh;
}Key Takeaways
- Structure matters — Separate concerns from day one
- Validate everything — Never trust client input
- Handle errors globally — Consistent error responses
- Secure by default — Helmet, CORS, rate limiting
- Cache aggressively — Redis for frequently accessed data
- Monitor everything — Logging, metrics, and alerting
Learn Node.js API development in our web development courses with hands-on projects and mentorship.