Building Scalable APIs with Express and MongoDB

The short version: Express and MongoDB scale well until you skip indexing, misconfigure connection pools, or let middleware run in the wrong order. The performance wins are almost always in the database layer — indexes, aggregation pipelines, and correct connection pool sizing — not in the JavaScript layer.
Express and MongoDB are easy to start with. A working API with routes, a Mongoose model, and basic CRUD takes an afternoon. The problems appear at load: slow queries, connection pool exhaustion, unhandled promise rejections crashing the process, and middleware stacks that authenticate after parsing megabytes of JSON. This guide covers the architecture decisions that prevent those problems from becoming incidents.
Project structure that scales
Flat files work until the project grows. This structure keeps concerns separated without over-engineering:
src/
app.ts ← Express app setup (no server.listen here)
server.ts ← starts server, connects DB
config/
database.ts ← Mongoose connection with retry logic
env.ts ← validated environment variables
routes/
index.ts ← registers all route groups
orders.routes.ts
users.routes.ts
controllers/
orders.controller.ts
users.controller.ts
services/
orders.service.ts ← business logic, no Express objects
users.service.ts
models/
Order.model.ts
User.model.ts
middleware/
auth.middleware.ts
error.middleware.ts
validate.middleware.ts
utils/
catchAsync.ts
AppError.ts
Controllers handle HTTP. Services handle business logic. Models define schema. No business logic in routes, no database calls in controllers.
MongoDB connection: do this once, do it right
// config/database.ts
import mongoose from 'mongoose';
const MONGO_URI = process.env.MONGO_URI!;
export async function connectDatabase(): Promise<void> {
mongoose.set('strictQuery', true);
await mongoose.connect(MONGO_URI, {
maxPoolSize: 20, // concurrent connections to MongoDB
minPoolSize: 5, // keep 5 connections warm
connectTimeoutMS: 10000,
socketTimeoutMS: 45000,
serverSelectionTimeoutMS: 5000,
});
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected — attempting reconnect');
});
}
// server.ts — connect DB first, then start listening
import { connectDatabase } from './config/database';
import app from './app';
const PORT = process.env.PORT || 3000;
async function start() {
await connectDatabase();
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}
start().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
Never call mongoose.connect() inside route handlers or middleware. One connection pool, shared across the application lifetime.
Middleware ordering matters
Express executes middleware in registration order. Wrong order means wasted work — or security holes.
// app.ts
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { errorHandler } from './middleware/error.middleware';
const app = express();
// 1. Security headers — before anything else
app.use(helmet());
// 2. Rate limiting — before parsing bodies (reject early, save CPU)
app.use('/api', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
}));
// 3. Body parsing — with size limits (prevent payload attacks)
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// 4. Routes
app.use('/api/v1/orders', ordersRouter);
app.use('/api/v1/users', usersRouter);
// 5. 404 handler — after all routes
app.use((req, res) => res.status(404).json({ error: 'Route not found' }));
// 6. Error handler — must be last, must have 4 parameters
app.use(errorHandler);
export default app;
Rate limiting before body parsing is intentional. A request that exceeds the rate limit gets rejected before Express parses the body — this matters when attackers send large payloads to exhaust memory.
Error handling: the centralized pattern
Unhandled promise rejections are the most common cause of unexpected Node.js process crashes in Express APIs.
// utils/AppError.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
// utils/catchAsync.ts
import { Request, Response, NextFunction } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export const catchAsync = (fn: AsyncHandler) =>
(req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next); // passes any rejection to Express error handler
};
// middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/AppError';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
return;
}
// MongoDB duplicate key error
if ((err as any).code === 11000) {
const field = Object.keys((err as any).keyValue)[0];
res.status(409).json({ status: 'error', message: `${field} already exists` });
return;
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values((err as any).errors).map((e: any) => e.message);
res.status(400).json({ status: 'error', message: messages.join(', ') });
return;
}
// Unknown error — don't expose internals in production
console.error('Unhandled error:', err);
res.status(500).json({
status: 'error',
message: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
});
}
With catchAsync, route handlers are clean:
// controllers/orders.controller.ts
export const createOrder = catchAsync(async (req, res, next) => {
const order = await OrderService.create(req.body, req.user.id);
res.status(201).json({ status: 'success', data: order });
});
export const getOrder = catchAsync(async (req, res, next) => {
const order = await OrderService.findById(req.params.id);
if (!order) return next(new AppError('Order not found', 404));
res.json({ status: 'success', data: order });
});
No try/catch in every handler. Errors propagate to the centralized handler automatically.
MongoDB indexing: where performance actually comes from
// models/Order.model.ts
import { Schema, model } from 'mongoose';
const orderSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
status: { type: String, enum: ['pending', 'confirmed', 'shipped', 'delivered'], default: 'pending' },
createdAt: { type: Date, default: Date.now },
total: Number,
items: [{
productId: Schema.Types.ObjectId,
quantity: Number,
price: Number,
}],
});
// Single field index — fast lookups by userId
orderSchema.index({ userId: 1 });
// Compound index — covers queries that filter by userId AND sort by createdAt
// Order matters: put the equality filter field first
orderSchema.index({ userId: 1, createdAt: -1 });
// Compound index for status-based queries with recency sort
orderSchema.index({ status: 1, createdAt: -1 });
export const Order = model('Order', orderSchema);
Check if your queries are hitting indexes:
// In MongoDB shell or Compass
db.orders.find({ userId: ObjectId("..."), status: "pending" })
.sort({ createdAt: -1 })
.explain("executionStats")
// Look for: winningPlan.stage === "IXSCAN" (good)
// Avoid: winningPlan.stage === "COLLSCAN" (bad — full scan)
// Check: totalDocsExamined vs nReturned — should be close to equal
A query returning 10 documents after examining 500,000 needs an index.
Aggregation pipeline for real queries
Fetching and joining in JavaScript is slow. Push the work to MongoDB:
// Service: orders with user data and computed total, paginated
async function getOrdersWithUsers(page: number, limit: number) {
const skip = (page - 1) * limit;
const [result] = await Order.aggregate([
// Stage 1: filter
{ $match: { status: 'confirmed' } },
// Stage 2: join user data
{
$lookup: {
from: 'users',
localField: 'userId',
foreignField: '_id',
as: 'user',
pipeline: [{ $project: { name: 1, email: 1 } }], // only fetch needed fields
}
},
{ $unwind: '$user' },
// Stage 3: compute item total
{
$addFields: {
itemCount: { $size: '$items' },
computedTotal: { $sum: '$items.price' },
}
},
// Stage 4: paginate with total count in one query
{
$facet: {
data: [{ $sort: { createdAt: -1 } }, { $skip: skip }, { $limit: limit }],
total: [{ $count: 'count' }],
}
},
]);
return {
orders: result.data,
total: result.total[0]?.count ?? 0,
pages: Math.ceil((result.total[0]?.count ?? 0) / limit),
};
}
One aggregation pipeline replaces: fetch orders → loop → fetch user for each → compute totals → count separately. The $facet stage returns data and total count in a single query.
Graceful shutdown
Serverless and containerized deployments send SIGTERM before terminating a process. Without graceful shutdown, in-flight requests get cut off.
// server.ts
const server = app.listen(PORT);
async function shutdown(signal: string) {
console.log(`${signal} received — shutting down gracefully`);
server.close(async () => {
await mongoose.connection.close();
console.log('Database connection closed');
process.exit(0);
});
// Force exit after 10 seconds if connections don't drain
setTimeout(() => process.exit(1), 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
For teams deploying Node.js APIs as serverless functions, the serverless edge functions guide covers how Express-style API routes fit into edge + regional hybrid architectures and how to handle the connection pooling problem that serverless deployments introduce.
Frequently Asked Questions

Written by
FNA Team
CEO & Founder at FNA Technology
Specializing in AI, automation, and scalable software solutions — helping businesses leverage cutting-edge technology to drive growth and innovation.
Work with us