Building Scalable APIs with Node.js and Express
Learn how to design and implement RESTful APIs that can handle growth and maintain performance.
Building APIs that scale requires careful planning and the right architectural decisions. In this guide, we'll explore key principles and patterns for creating scalable RESTful APIs with Node.js and Express.
What Makes an API Scalable?
A scalable API should:
- Handle increasing load gracefully
- Maintain performance under stress
- Be easy to maintain and extend
- Have clear separation of concerns
- Implement proper error handling
1. Layered Architecture
Organize your application into distinct layers:
// controllers/userController.js
const { getUserById, createUser } = require("../services/userService");
const { validateUser } = require("../utils/validation");
exports.getUser = async (req, res) => {
try {
const { id } = req.params;
const user = await getUserById(id);
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
};
// services/userService.js
const { User } = require("../models/user");
exports.getUserById = async (id) => {
const user = await User.findById(id);
if (!user) {
throw new Error("User not found");
}
return user;
};Separate business logic from HTTP handling. This makes your code more testable and maintainable.
2. Database Optimization
Connection Pooling
const { Pool } = require("pg");
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
max: 20, // Maximum number of connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
module.exports = pool;Query Optimization
// Bad: N+1 query problem
async function getUsersWithPosts() {
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
return users;
}
// Good: Use joins
async function getUsersWithPosts() {
return await User.findAll({
include: [
{
model: Post,
as: "posts",
},
],
});
}3. Caching Strategy
Implement Redis for caching frequently accessed data:
const redis = require("redis");
const client = redis.createClient();
async function getCachedUser(id) {
const cacheKey = `user:${id}`;
// Try cache first
const cached = await client.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - fetch from database
const user = await getUserById(id);
// Cache for 1 hour
await client.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}4. Rate Limiting
Protect your API from abuse:
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: {
error: "Too many requests from this IP",
},
standardHeaders: true,
legacyHeaders: false,
});
app.use("/api/", limiter);5. Asynchronous Processing
For long-running tasks, use a message queue:
const Queue = require("bull");
const emailQueue = new Queue("email processing");
app.post("/api/send-bulk-email", async (req, res) => {
const { emails, template } = req.body;
// Add to queue instead of processing immediately
emails.forEach((email) => {
emailQueue.add("send-email", { email, template });
});
res.json({ message: "Emails queued for processing" });
});
// Worker process
emailQueue.process("send-email", async (job) => {
const { email, template } = job.data;
await sendEmail(email, template);
});6. Monitoring and Logging
Implement comprehensive logging:
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration,
userAgent: req.get("User-Agent"),
});
});
next();
});7. Environment Configuration
Use environment-specific configurations:
const config = {
development: {
database: {
host: "localhost",
dialect: "postgres",
},
redis: {
host: "localhost",
port: 6379,
},
},
production: {
database: {
host: process.env.DB_HOST,
dialect: "postgres",
pool: {
max: 50,
min: 5,
},
},
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
},
},
};
module.exports = config[process.env.NODE_ENV || "development"];8. Health Checks
Implement health check endpoints:
app.get("/health", async (req, res) => {
const health = {
status: "ok",
timestamp: new Date().toISOString(),
services: {},
};
try {
// Check database
await db.authenticate();
health.services.database = "healthy";
} catch (error) {
health.services.database = "unhealthy";
health.status = "error";
}
try {
// Check Redis
await redis.ping();
health.services.redis = "healthy";
} catch (error) {
health.services.redis = "unhealthy";
health.status = "error";
}
const statusCode = health.status === "ok" ? 200 : 503;
res.status(statusCode).json(health);
});Health checks enable load balancers and monitoring systems to automatically detect and route around unhealthy instances.
Conclusion
Building scalable APIs requires attention to architecture, performance, and monitoring. Key takeaways:
- Use layered architecture for maintainability
- Implement proper database optimization
- Cache frequently accessed data
- Protect against abuse with rate limiting
- Use queues for long-running tasks
- Monitor everything that matters
- Configure for different environments
- Implement health checks
Start with these patterns and adapt them to your specific requirements. Remember that scalability is a journey, not a destination.