+1 (726) 227-3745

Load Testing a MEAN API Before Launch: k6, Express 5 and MongoDB 8 Capacity Planning

Every MEAN app we are asked to rescue after a bad launch had the same thing in common: nobody ever ran it under load before real users arrived. Tracing and heap profiling tell you why a production app is slow after it is slow. Load testing tells you what will break, at what concurrency, and in which layer, while you can still fix it cheaply.

This tutorial walks through the load test we run before a client's MEAN app goes live: a k6 script against an Express 5 / Mongoose 8 / MongoDB 8 API, realistic data, thresholds that double as SLOs, and the three bottlenecks that show up almost every time.

0. Set an SLO before you write a script

A load test without a pass/fail line is just a graph. Agree on numbers first. A typical starting point for an internal business app:

  • p95 latency under 400 ms for read endpoints, under 800 ms for writes.
  • Error rate under 0.5%.
  • Target load: peak concurrent users x requests per user per minute, plus 2x headroom.

Write those down; in step 3 they become k6 thresholds, so the test exits non-zero when you miss them.

1. A realistic environment

Load testing against localhost with ten documents in MongoDB measures nothing. Three rules:

  1. Run the API on hardware that resembles production. Same container image, same CPU/memory limits, NODE_ENV=production.
  2. Seed production-scale data. Index selectivity changes completely between 1,000 and 5,000,000 documents.
  3. Drive load from a different machine than the one under test, or you are benchmarking your laptop's scheduler.

A quick seeder with Mongoose 8:

// scripts/seed.js  —  node scripts/seed.js 2000000
import mongoose from 'mongoose';
import { Task, User } from '../src/models.js';

const total = Number(process.argv[2] ?? 1_000_000);
await mongoose.connect(process.env.MONGODB_URI);

const users = await User.insertMany(
  Array.from({ length: 1000 }, (_, i) => ({ email: `load${i}@example.test`, passwordHash: 'x' })),
  { ordered: false },
);

const BATCH = 10_000;
for (let written = 0; written < total; written += BATCH) {
  await Task.insertMany(
    Array.from({ length: BATCH }, () => ({
      owner: users[Math.floor(Math.random() * users.length)]._id,
      title: `task ${Math.random().toString(36).slice(2)}`,
      done: Math.random() < 0.3,
    })),
    { ordered: false, lean: true },
  );
  process.stdout.write(`\r${written + BATCH}/${total}`);
}
await mongoose.disconnect();

Dump the seeded user ids to users.json; the k6 script logs in as a random one so every virtual user hits a different slice of data. Testing one hot user warms one cache and lies to you.

2. Pick the tool for the question

  • autocannon — a single endpoint, a single question ("how many requests per second can this route serve?"). Thirty seconds of work: npx autocannon -c 100 -d 30 http://api.internal/api/health.
  • k6 — multi-step user journeys, thresholds, ramping stages, CI integration. This is what we use for anything resembling a real test.
  • Artillery — comparable to k6; pick one and get good at it.

Install k6 from your package manager (brew install k6, winget install k6, or the Debian repo). Scripts are JavaScript; they run in k6's own Go-hosted runtime, not Node, so there is no npm install inside a test.

3. The k6 script

// loadtest/journey.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Rate } from 'k6/metrics';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', () => JSON.parse(open('./users.json')));

const listTrend = new Trend('list_tasks_ms', true);
const createTrend = new Trend('create_task_ms', true);
const businessErrors = new Rate('business_errors');

const BASE = __ENV.BASE_URL ?? 'http://api.internal';

export const options = {
  scenarios: {
    steady: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 50 },   // ramp
        { duration: '5m', target: 50 },   // plateau: this is the number that matters
        { duration: '2m', target: 200 },  // stress beyond expected peak
        { duration: '3m', target: 200 },
        { duration: '1m', target: 0 },    // ramp down, watch recovery
      ],
    },
  },
  thresholds: {
    'http_req_failed': ['rate<0.005'],
    'list_tasks_ms': ['p(95)<400'],
    'create_task_ms': ['p(95)<800'],
    'business_errors': ['rate<0.01'],
  },
};

export function setup() {
  const res = http.get(`${BASE}/api/health`);
  check(res, { 'API is up before the test': (r) => r.status === 200 });
}

export default function () {
  const user = users[Math.floor(Math.random() * users.length)];

  const login = http.post(`${BASE}/api/login`, JSON.stringify({ email: user.email, password: user.password }), {
    headers: { 'Content-Type': 'application/json' },
    tags: { name: 'POST /api/login' },
  });
  if (!check(login, { 'login 200': (r) => r.status === 200 })) {
    businessErrors.add(1);
    return;
  }
  const auth = { headers: { Authorization: `Bearer ${login.json('token')}`, 'Content-Type': 'application/json' } };

  const list = http.get(`${BASE}/api/tasks?limit=20`, { ...auth, tags: { name: 'GET /api/tasks' } });
  listTrend.add(list.timings.duration);
  businessErrors.add(!check(list, { 'list 200': (r) => r.status === 200 }));

  sleep(Math.random() * 3 + 1); // think time: users read before they click

  const created = http.post(`${BASE}/api/tasks`, JSON.stringify({ title: `k6 ${__VU}-${__ITER}` }), {
    ...auth,
    tags: { name: 'POST /api/tasks' },
  });
  createTrend.add(created.timings.duration);
  businessErrors.add(!check(created, { 'create 201': (r) => r.status === 201 }));

  sleep(Math.random() * 5 + 2);
}

Two details people get wrong. Think time: without sleep(), 50 virtual users generate the load of about 5,000 real ones and your capacity numbers are meaningless. Tags: tags: { name: ... } groups URLs with ids in them, otherwise k6 reports ten thousand distinct endpoints.

Run it:

k6 run -e BASE_URL=https://staging-api.example.com loadtest/journey.js

k6 exits with code 99 when a threshold fails, which is exactly what you want in a pipeline.

4. Watch the API, not just the client

The k6 summary tells you that p95 moved. To find out why, record three things on the server during the run.

Event loop delay — if this climbs, the problem is CPU in Node, not the database:

// src/health.js
import { monitorEventLoopDelay } from 'node:perf_hooks';
const loop = monitorEventLoopDelay({ resolution: 10 });
loop.enable();

export function healthHandler(req, res) {
  res.json({
    ok: true,
    rss_mb: Math.round(process.memoryUsage().rss / 1e6),
    loop_p99_ms: Math.round(loop.percentile(99) / 1e6),
  });
}

Poll /api/health once a second during the test. A p99 loop delay above ~50 ms means something synchronous is blocking: JSON serialization of huge documents, argon2 hashing on the main thread, a regex, or template rendering.

MongoDB slow queries — set the profiler to log anything over 100 ms before the run:

db.setProfilingLevel(1, { slowms: 100 });
// after the run:
db.system.profile.aggregate([
  { $group: { _id: { ns: '$ns', plan: '$planSummary' }, n: { $sum: 1 }, avgMs: { $avg: '$millis' } } },
  { $sort: { avgMs: -1 } },
]);

Any planSummary: COLLSCAN under load is your bottleneck. $currentOp and the Atlas performance advisor give the same answer from the other direction.

Connection pool saturation. Mongoose 8's default pool is 100 sockets per process. Instrument it:

mongoose.connection.on('connected', () => {
  const client = mongoose.connection.getClient();
  client.on('connectionCheckOutFailed', () => console.warn('mongo pool exhausted'));
  client.on('connectionCheckedOut', () => poolInUse++);
  client.on('connectionCheckedIn', () => poolInUse--);
});

If poolInUse pins at maxPoolSize while CPU is idle, requests are queuing for a connection, and the fix is faster queries, not a bigger pool. Raising the pool just moves the queue into the database.

5. The three bottlenecks we find almost every time

1. A missing compound index. Latency is flat at 20 VUs and hockey-sticks at 60. explain('executionStats') on the slow query shows totalDocsExamined far above nReturned. One { owner: 1, createdAt: -1 } index usually collapses p95 by an order of magnitude. Index design in depth is covered in our MongoDB 8 index and aggregation tuning post.

2. Unbounded result sets. Task.find({ owner }) with no limit is fine in dev and fatal with 50,000 documents per user: Mongo ships megabytes, Node serializes them on the main thread, event loop delay spikes, every endpoint slows down. Enforce a hard cap in the handler:

const Query = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().optional(),
});

Cursor pagination (_id > cursor) rather than skip, which gets linearly slower the deeper you page.

3. Password hashing on the request path at login peak. argon2 is deliberately expensive. A hundred concurrent logins will saturate every core. Tune the memory/time cost to your hardware (~100 ms per hash is a reasonable target), and make sure your load profile reflects reality: real users log in once and then make fifty authenticated requests, not one login per request.

Honourable mentions: no limit on $lookup aggregations, logging full request bodies at info, and a sticky-session-free deploy where every instance keeps its own in-memory cache.

6. Make it a build step, not a ritual

Run a short smoke-load test on every merge to main and the full ramp nightly:

# .github/workflows/loadtest.yml
name: load test
on:
  schedule: [{ cron: '0 3 * * *' }]
  workflow_dispatch:
jobs:
  k6:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: grafana/setup-k6-action@v1
      - run: k6 run -e BASE_URL=${{ secrets.STAGING_API_URL }} loadtest/journey.js
        # exits 99 if a threshold is breached -> job fails
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: k6-summary, path: summary.json }

Keep each run's summary. The useful artefact is not a single number, it is the trend: the week p95 jumps 40% is the week to go looking at the diff, while the change is still small.

7. A reporting checklist

When we hand a load test report to a client, it answers five questions:

  1. What load did the system sustain within SLO? (The plateau number, not the peak before errors.)
  2. Where did it break first, and in which layer?
  3. What was the failure mode: slow, errors, or crash-and-restart?
  4. Did it recover cleanly when load dropped?
  5. What is the cheapest next fix, and what headroom does it buy?

If you cannot answer those about your own app, you do not know whether launch day will go well, you are hoping. If you want a second pair of eyes on the numbers, or a capacity plan before a campaign or a migration, our performance tuning service does exactly this, and MEAN Stack Testing covers the rest of the pyramid. Get in touch with your endpoints and target load and we will tell you what we would test first.