Building Scalable APIs with Node.js
Node.js is a solid choice for building scalable APIs. Its event-driven, non-blocking architecture makes it well suited for handling high concurrency, and its ecosystem provides mature tooling for production systems.
In this guide, we’ll walk through architectural patterns, performance considerations, and best practices for building APIs that are reliable and ready for real-world traffic.
Why Node.js for APIs?
Node.js offers several advantages for API development:
- Non-blocking I/O – Efficiently handle large numbers of concurrent requests
- Single language across the stack – JavaScript on both backend and frontend
- Mature ecosystem – Extensive libraries available via npm
- Good performance characteristics – Especially for I/O-bound workloads
Project Structure
A clear and consistent project structure makes APIs easier to maintain and scale.
src/
├── routes/ # API endpoints
├── controllers/ # Request handlers
├── services/ # Business logic and integrations
├── middleware/ # Cross-cutting concerns
├── models/ # Data schemas
├── utils/ # Shared helpers
├── config/ # Configuration
└── server.ts # Application entry point
This separation keeps responsibilities clear and reduces coupling as the codebase grows.
Express.js Setup
A minimal but production-ready Express setup should include security headers, logging, and request parsing.
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
const app = express();
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
export default app;
Controller Pattern
Controllers should remain thin and delegate business logic to services.
// controllers/userController.ts
import { Request, Response } from 'express';
import { userService } from '../services/userService';
export const getUserById = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const user = await userService.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
} catch {
res.status(500).json({ error: 'Internal server error' });
}
};
Routing
Routes should focus on HTTP concerns and middleware composition.
// routes/users.ts
import express from 'express';
import {
getUserById,
createUser,
updateUser
} from '../controllers/userController';
import { validateUser } from '../middleware/validation';
import { authenticate } from '../middleware/auth';
const router = express.Router();
router.get('/:id', getUserById);
router.post('/', validateUser, createUser);
router.put('/:id', authenticate, validateUser, updateUser);
export default router;
Middleware Best Practices
Authentication
Authentication logic should be centralized and reusable.
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
): void => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Missing token' });
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
(req as any).user = decoded;
next();
} catch {
res.status(403).json({ error: 'Invalid token' });
}
};
Error Handling
Centralized error handling keeps responses consistent.
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number
) {
super(message);
}
}
export const errorHandler = (
error: Error | AppError,
req: Request,
res: Response,
next: NextFunction
): void => {
if (error instanceof AppError) {
res.status(error.statusCode).json({
error: error.message,
statusCode: error.statusCode
});
} else {
res.status(500).json({
error: 'Internal server error'
});
}
};
Database Optimization
Connection Pooling
Using a connection pool prevents excessive database connections.
import { Pool } from 'pg';
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const query = (text: string, params?: any[]) => {
return pool.query(text, params);
};
Query Optimization
Avoid inefficient access patterns such as N+1 queries.
// Inefficient
const users = await User.find();
for (const user of users) {
user.posts = await Post.find({ userId: user.id });
}
// Optimized
const users = await User.find()
.populate('posts')
.limit(10);
Caching Strategies
Redis Integration
Caching frequently accessed data reduces database load.
import redis from 'redis';
const client = redis.createClient();
export const getCachedUser = async (userId: string) => {
const cached = await client.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
const user = await User.findById(userId);
await client.setEx(
`user:${userId}`,
1800,
JSON.stringify(user)
);
return user;
};
Rate Limiting
Protect APIs from abuse by limiting request rates.
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests'
});
app.use('/api/', limiter);
Validation
Validate all external input.
import { body, validationResult } from 'express-validator';
export const validateUser = [
body('email').isEmail().normalizeEmail(),
body('name').isLength({ min: 2 }).trim(),
body('age').optional().isInt({ min: 18, max: 120 }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ errors: errors.array() });
return;
}
next();
}
];
Environment Configuration
Centralize configuration and avoid hardcoding values.
export const config = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL,
pool: {
max: parseInt(process.env.DB_POOL_MAX || '20'),
}
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '24h'
},
cors: {
origin: process.env.CORS_ORIGIN || '*'
}
};
Performance Monitoring
Log request metrics to understand system behavior.
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
}
});
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${Date.now() - start}ms`
});
});
next();
});
Deployment Considerations
npm run build
NODE_ENV=production node dist/server.js
Using a process manager:
pm2 start dist/server.js -i max --name "api"
Conclusion
Scalable APIs are built through deliberate design, not shortcuts. Focus on clear architecture, defensive coding, and observability from day one.
Key Takeaways
- Keep controllers thin
- Validate all inputs
- Cache strategically
- Use connection pooling
- Monitor performance continuously
- Plan for scale early