Back to Blog

How We Load Tested a SaaS API Before Launch — Tools, Results and What We Fixed

Published: June 24, 2026
How We Load Tested a SaaS API Before Launch — Tools, Results and What We Fixed

Every SaaS founder eventually asks the load testing question a week before launch, usually phrased as "how hard could it be?" The answer is: hard enough that finding out at 2am on launch day with a Slack notifications tab you're too afraid to open is a rite of passage we'd rather you skip.

We load test before every launch. Not because we enjoy watching p95 latency charts go sideways, but because the alternative — finding the bottleneck when real users are hitting the API and the error rate is climbing — costs more than the testing ever will. For 90% of mid-size and large enterprises, a single hour of downtime now costs over $300,000 (ITIC, 2024). That makes load testing not a "nice to have." It's an insurance policy with an absurd ROI.

Here is exactly how we load test a SaaS API using k6, the three bottlenecks we found before they became production incidents, and the exact fixes that turned a struggling API into one that handled 10x the traffic without breaking a sweat.

Close-up of server equipment in a modern data center representing saas api load testing before launch with server infrastructure monitoring

Tool Selection: k6 vs Artillery vs Locust

We chose k6, but not because the others are bad. Here is the decision:

k6 (Grafana). JavaScript-based, developer-friendly, runs on Go under the hood so it generates serious load from a single machine. Scripts are plain JavaScript — no YAML DSL, no XML, no config-file syntax to fight. It has built-in support for custom metrics, thresholds, and CI/CD integration. The Grafana k6 documentation is excellent, and the community is large enough that you will find a Stack Overflow answer for whatever weird edge case you hit.

Artillery. YAML-based, great if you already use Playwright for E2E testing and want to reuse the same mental model. The Artillery documentation is solid, but YAML-based load test configuration starts to feel constrained when you need conditional logic or dynamic request generation. k6's JavaScript gives you more flexibility without adding complexity.

Locust. Python-based, ideal when your team is Python-heavy or you need to test protocols beyond HTTP (gRPC, custom TCP). Locust is well proven — EA uses it for Battlefield. But per-virtual-user overhead is higher than k6 at scale, so generating 10,000 concurrent users requires more hardware.

We went with k6. If you are building a NestJS SaaS with a TypeScript team, k6 is the natural fit — your developers already know JavaScript, the scripting model maps directly to your API structure, and it integrates with the Grafana observability stack you are probably already running.

Designing Realistic Load Test Scenarios

This is where most load tests fail. They hit one endpoint — usually /health or /status — in a tight loop and call it a day. A single-endpoint load test tells you almost nothing about how your SaaS will behave under real traffic, because real users do not arrive at evenly spaced intervals and hammer one route.

Here is how we design our scenarios:

Map the user journeys. Look at your application logs or browser analytics. What are the three most common paths through your SaaS? For a typical B2B product, it is login → dashboard overview → entity detail page, with occasional write actions scattered through. Weigh each journey by real-world frequency. If 60% of users hit the dashboard and 10% create an invoice, your test ratio should reflect that.

Vary think times. Users do not fire requests at a fixed 1-second interval. They read, they hesitate, they switch tabs. Use k6's sleep() with a random range to simulate real human pacing. This prevents the "thundering herd" pattern where all virtual users arrive at the next endpoint simultaneously.

Ramp up realistically. Launching 500 virtual users at once is not realistic unless you are running a Black Friday sale. Start low, climb gradually, and watch where the metrics start to degrade.

Hand holding smartphone displaying network analysis in high-tech server environment representing saas api load testing before launch and performance monitoring

Our k6 Test Script: Simulated User Journeys

Here is the actual test script we used. It simulates a logged-in user loading the dashboard, viewing recent orders, and occasionally creating a new record:

JavaScript
1import http from 'k6/http';
2import { check, sleep, group } from 'k6';
3import { Rate, Trend } from 'k6/metrics';
4
5const errorRate = new Rate('errors');
6const dashboardLoad = new Trend('dashboard_load_time');
7
8export const options = {
9  stages: [
10    { duration: '2m', target: 20 },   // ramp up
11    { duration: '5m', target: 50 },   // sustain
12    { duration: '2m', target: 100 },  // ramp further
13    { duration: '5m', target: 100 },  // sustain peak
14    { duration: '2m', target: 0 },    // ramp down
15  ],
16  thresholds: {
17    http_req_duration: ['p(95)<800', 'p(99)<2000'],
18    errors: ['rate<0.05'],
19  },
20};
21
22const BASE_URL = __ENV.API_URL || 'http://localhost:3000/api';
23
24export default function () {
25  group('auth', () => {
26    const payload = JSON.stringify({
27      email: `user_${__VU}@example.com`,
28      password: 'test_password_123',
29    });
30    const res = http.post(`${BASE_URL}/auth/login`, payload, {
31      headers: { 'Content-Type': 'application/json' },
32    });
33    check(res, { 'login status 200': (r) => r.status === 200 });
34    errorRate.add(res.status !== 200);
35  });
36
37  sleep(Math.random() * 2 + 1);
38
39  group('dashboard', () => {
40    const start = Date.now();
41    const res = http.get(`${BASE_URL}/dashboard/overview`, {
42      headers: { Authorization: `Bearer ${__VU}` },
43    });
44    dashboardLoad.add(Date.now() - start);
45    check(res, { 'dashboard status 200': (r) => r.status === 200 });
46    errorRate.add(res.status !== 200);
47  });
48
49  sleep(Math.random() * 3 + 2);
50
51  group('recent orders', () => {
52    const res = http.get(`${BASE_URL}/orders?limit=20&status=active`, {
53      headers: { Authorization: `Bearer ${__VU}` },
54    });
55    check(res, { 'orders status 200': (r) => r.status === 200 });
56    errorRate.add(res.status !== 200);
57  });
58
59  if (Math.random() < 0.2) {
60    sleep(Math.random() * 2 + 1);
61    group('create invoice', () => {
62      const payload = JSON.stringify({
63        clientId: 'client_123',
64        amount: 1500,
65        currency: 'USD',
66      });
67      const res = http.post(`${BASE_URL}/invoices`, payload, {
68        headers: {
69          'Content-Type': 'application/json',
70          Authorization: `Bearer ${__VU}`,
71        },
72      });
73      check(res, { 'invoice created': (r) => r.status === 201 });
74      errorRate.add(res.status !== 201);
75    });
76  }
77
78  sleep(Math.random() * 2 + 1);
79}

Three things to notice about this script:

Weighted journeys. The "create invoice" action runs only 20% of the time per iteration, matching our real-world ratio of read-heavy to write-heavy traffic.

Custom metrics. dashboardLoad tracks a specific endpoint's response time separately so we can spot problems in a single route without parsing aggregate data.

Thresholds. If p95 response time exceeds 800ms or the error rate goes above 5%, the test fails in CI. These thresholds become the contract your API must meet before a deploy goes out.

Running the Test: Ramp-Up, Sustained Load, and Spike

We run three test profiles:

Ramp-up and sustain. Gradually increase virtual users from 0 to the target (50, then 100) and hold. This finds the saturation point — the exact concurrency level where latency starts to climb or errors start to appear. Most APIs can handle a gradual increase; the interesting number is where they stop.

Spike test. Jump from 20 to 200 virtual users in 30 seconds. This simulates a real-world traffic surge — a marketing email going out, a featured listing on Product Hunt, an enterprise client's team all logging in at 9am. Spikes find the bottlenecks that ramp-up tests miss because the sudden load skips past the database connection pool's slow-growing buffer.

Soak test (extended). Hold sustained load for 30-60 minutes. Memory leaks, connection leaks, and slow-growing resource exhaustion only show up under sustained pressure. The server that handles 100 users for 5 minutes might crash at 35 minutes when garbage collection catches up or connection pool slots leak away.

What We Found: The Unexpected Bottlenecks

The first run was not terrible. The API handled 50 concurrent users with acceptable latency — p95 around 400ms, no errors. At 100 concurrent users, things got interesting. The dashboard endpoint's p95 jumped to 2.3 seconds. Error rate climbed to 8%. The dashboard_load_time custom metric told the story: the overview endpoint was the problem.

But there was not one problem. There were three, stacked on top of each other, and fixing any one of them would not have fixed the overall picture. Here is what we found.

Fix 1: Database Connection Pool Was Too Small

NestJS uses the DataSource from TypeORM (or Prisma's connection management), which defaults to a connection pool of 10. That is fine for sequential request handling with a handful of users. Under 100 concurrent requests, with each request needing to query the database, those 10 connection slots become a queue. Requests wait for a connection to free up. Latency climbs. Timeouts fire.

The fix was straightforward — increase the pool size and add PgBouncer on top:

TypeScript
1// app.datasource.ts
2import { DataSource } from 'typeorm';
3
4export const AppDataSource = new DataSource({
5  type: 'postgres',
6  host: process.env.DB_HOST,
7  port: parseInt(process.env.DB_PORT || '5432'),
8  username: process.env.DB_USER,
9  password: process.env.DB_PASSWORD,
10  database: process.env.DB_NAME,
11  extra: {
12    max: 25,                  // was 10
13    idleTimeoutMillis: 30000,
14    connectionTimeoutMillis: 5000,
15  },
16});

After fix: The connection queue disappeared. But the dashboard endpoint was still slow.

Fix 2: One Endpoint Doing 12 Database Queries

The dashboard overview endpoint was loading the same data set we already optimized in a previous performance pass — except the load test revealed something our development database never did: the NestJS serialisation interceptor was triggering lazy-loaded relations.

The endpoint returned the dashboard summary with related entities. In development, with a single user, these loaded fast enough that nobody noticed. Under load, each of the 12 lazy queries added 30-80ms of database round-trip time. Multiplied by 100 concurrent users, the database spent most of its time on connection handshakes for tiny queries.

The fix: eager-load everything in a single query:

TypeScript
1// dashboard.service.ts
2async getOverview(tenantId: string) {
3  const dashboard = await this.dashboardRepository.findOne({
4    where: { tenantId },
5    relations: {
6      recentOrders: true,
7      topClients: true,
8      revenueSummary: true,
9      activeProjects: true,
10    },
11  });
12  // With the relations option, TypeORM generates a single
13  // query with JOINs instead of 12 individual queries
14  return this.dashboardMapper.toResponse(dashboard);
15}

After fix: The dashboard endpoint's p95 dropped from 2.3s to 640ms. Twelve queries became one.

Fix 3: Missing Index on a High-Traffic Query

Even with the connection pool fixed and the N+1 resolved, the orders endpoint was still slower than expected — p95 around 1.1s at 100 concurrent users. We ran an EXPLAIN ANALYZE on the query the endpoint was generating:

SQL
1EXPLAIN ANALYZE
2SELECT * FROM orders
3WHERE tenant_id = 'abc123'
4  AND status = 'active'
5ORDER BY created_at DESC
6LIMIT 20;

Sequential scan on orders. The table had indexes on tenant_id and created_at separately, but the query filtered on both columns with an equality on status and a sort on created_at. A composite index handled all three conditions in one pass:

SQL
1CREATE INDEX idx_orders_tenant_status_created
2ON orders (tenant_id, status, created_at DESC);

After fix: The orders endpoint's p95 dropped from 1.1s to 180ms. The sequential scan became an index-only scan.

Results After Fixes: 10x Throughput Improvement

Here is the before-and-after under the same load profile — 100 concurrent users, 12-minute test:

MetricBefore FixesAfter FixesImprovement
Requests/sec1451,4209.8x
p95 latency2,340ms410ms5.7x
p99 latency4,100ms890ms4.6x
Error rate8.2%0.3%27x
Database CPU78%34%2.3x less

We did not add a single server. We did not add Redis (that came later, in a separate caching strategy pass). We did not "scale horizontally" or "microservice" anything. We fixed a connection pool number, killed a lazy-load pattern, and added a composite index.

A woman using a laptop navigating a contemporary data center with mirrored servers representing saas api load testing before launch infrastructure testing

Why This Matters Before Launch

Load testing the week before launch is not ideal. Load testing the day before is terrifying. But load testing after launch, when real users are waiting and the error rate is climbing — that is where careers take interesting turns.

Most API performance issues are not architectural. They are query problems, connection pool ceilings, and missing indexes wearing the costume of "we need to scale." The 8-second dashboard we cut to 340ms did not need a shard — it needed the same kind of fixes we just described: a missing index, a bad JOIN, and an N+1 in the ORM.

The same principle applies here. Before you plan the Kubernetes migration, before you open a Redis cluster ticket, before you start the third-pillar database conversation — run a load test with realistic user journeys and look at the p95 of each endpoint individually. The bottleneck is almost always smaller and more boring than you think.

Running Load Tests in CI

One last thing: make load testing part of your CI pipeline. We run the k6 test suite against every staging deploy with a reduced target (20 concurrent users, 3-minute duration) as a smoke test. Full-scale tests run nightly and before every production release. It takes fifteen minutes of engineer time to set up the initial script and about five seconds of review per deploy to check whether the thresholds passed.

The Grafana k6 documentation covers the CI integration in detail — GitHub Actions, GitLab CI, Jenkins, all of them supported natively.

Pick the load testing tool that fits your stack. Model your tests on real user behaviour, not your API's OpenAPI spec. Fix the boring problems first — pool sizes, N+1 queries, missing indexes — before you reach for the exciting infrastructure. You will probably find that you don't need the exciting infrastructure. And if you eventually do, the load test will tell you that too — honestly, with thresholds, and in enough time to do something about it.

Your launch will go fine. Probably better than fine. And if it does not, at least now you will know which three things to check before you start sweating — which is roughly three more than I knew the first time.

Frequently Asked Questions

k6 is the best choice for most SaaS teams. It is open-source, uses JavaScript for scripting (no YAML DSL to learn), supports custom metrics and thresholds, integrates with CI/CD pipelines, and runs efficiently enough to generate significant load from a single machine. Artillery is a solid alternative if you already use Playwright for E2E testing, since both tools share a YAML-based workflow. Locust is useful when your team is Python-heavy or needs to test custom protocols, but its per-user overhead is higher than k6 at scale.

Model your load test scenarios on real user behaviour rather than hitting a single endpoint in a loop. Use browser analytics or application logs to identify the most common user journeys — login, browse dashboard, view a detail page, trigger an action. Weigh each journey by its real-world frequency. Vary think times between requests to avoid thundering-herd patterns. Include ramp-up and spike profiles in addition to steady-state load. A test that only hits /health-check tells you nothing about what will break under real traffic.

Focus on four categories: throughput (requests per second), latency (p50, p95, p99 response times), error rate (percentage of non-2xx responses), and resource utilisation (CPU, memory, database connections, open file handles on the server). The p95 and p99 latencies reveal the tail-latency problems that real users feel even when the average looks fine. Monitor error codes separately — a spike in 502s and a spike in 429s are very different problems.

Increase the pool size in your NestJS datasource configuration, but not arbitrarily — every connection consumes RAM (~2-10 MB) and PostgreSQL has a hard limit set by max_connections. A better long-term fix is to add PgBouncer or another connection pooler between your app and the database. PgBouncer multiplexes many app-side connections into fewer database connections, allowing you to handle burst traffic without exhausting PostgreSQL resources. Monitor active vs idle connections to determine the right pool size for each service.

Enable query logging in TypeORM or Prisma during your load test and look for repeated identical queries with different WHERE parameters. The pattern is unmistakable: your log will show 100+ SELECT statements for individual records when one JOIN would have returned everything. Use your ORM's relation loading options — eager loading in TypeORM, include in Prisma — to batch-load related entities in a single query. After the fix, verify by comparing query counts and endpoint p95 latency before and under load.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio