FNA Technology Header Logo
ServicesWorkAboutBlog
[ start a project ]
FNA Technology Footer Logo

Transforming your digital vision into reality. Software, AI, web & mobile — built for outcomes.

[email protected] footer link+91 8879510299 footer link
Share page:
MAIN
HomeOur ServicesProjectsCompany Blog
COMPANY
About UsContact UsLinkedIn
LEGAL
Privacy PolicyTerms & Conditions
© 2026 FNA TECHNOLOGY LLP — ALL RIGHTS RESERVEDIndia · UK · Middle East
Building Scalable APIs with Express and MongoDBArchitectural best practices for building scalable APIs with Express and MongoDB. Learn database design, index strategies, and router structures.Business owners, developers, CTOsscalable APIs Express.js MongoDB, Express.js REST API production, MongoDB indexing strategies, Node.js API architecture, MongoDB connection pooling, Express middleware patterns, MongoDB aggregation pipelineFNA Technology
Web Development

Building Scalable APIs with Express and MongoDB

May 4, 2026
7 min read
FNA Technology
Express.js and MongoDB API architecture diagram showing connection pooling and indexing

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:

Code
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

Code
// 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');
  });
}
Code
// 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.

Code
// 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.

Code
// 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:

Code
// 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

Code
// 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:

Code
// 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:

Code
// 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.

Code
// 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

Missing indexes. A MongoDB collection query without an index performs a full collection scan — every document is checked. At 10,000 documents this is annoying. At 1 million documents it's a 500–2000ms query. Use explain() on slow queries to check if they hit an index (IXSCAN) or scan the collection (COLLSCAN). Add compound indexes for your most frequent query patterns. This single change typically produces the largest performance improvement in under-optimized MongoDB APIs.

The Mongoose default is 5 connections per pool. For production APIs handling concurrent requests, that's too low. A good starting point is 10–50 connections depending on your server count and MongoDB Atlas tier. Set it explicitly with mongoose.connect(uri, { maxPoolSize: 20 }). Too high is also a problem — MongoDB Atlas free and shared tiers cap simultaneous connections, and exceeding the limit causes connection errors under load.

Mongoose for most applications. It adds schema validation, middleware hooks, and population (references between documents) that save significant boilerplate. The native driver is faster and more flexible — use it when you need raw aggregation performance, are working with very large documents, or need features Mongoose doesn't expose cleanly. The performance difference is rarely the bottleneck; your indexes and query design matter more.

Centralize error handling in a single Express error middleware registered after all routes. Define a custom AppError class that carries an HTTP status code and operational flag. In async route handlers, wrap with a catchAsync utility that passes errors to next(). This pattern means every route handler is clean, and all error formatting — including hiding stack traces in production — happens in one place.

When you need to join data across collections, group and compute statistics, filter on computed fields, or reshape documents before returning them. The aggregation pipeline runs server-side in MongoDB, which is faster than fetching documents and processing them in Node.js. Common use cases: generating analytics summaries, paginating sorted results with counts, and joining user data with order records. Replace any pattern of 'fetch collection A, loop through results, fetch related documents from collection B' with a $lookup aggregation stage.

#scalable APIs Express.js MongoDB#Express.js REST API production#MongoDB indexing strategies#Node.js API architecture#MongoDB connection pooling#Express middleware patterns#MongoDB aggregation pipeline
Share this article:
FNA Technology

Written by

FNA Technology

Team Member at FNA Technology

FNA Technology is a software development company specializing in AI, mobile apps, and web solutions.

Work with us