DEV Community

yureki_lab
yureki_lab

Posted on

How I Let Claude Code Write My Load Tests and Caught 3 Bottlenecks Pre-Launch

TL;DR

I had never written a real load test before a launch. I let Claude Code build a k6 suite for my Node.js API in an afternoon, and it surfaced three bottlenecks that would have taken the service down on day one. This post walks through the exact prompts, the k6 setup, what broke, and the five lessons I'd apply to any perf-testing work with an AI coding agent. ⚡

The Problem

Two weeks before launching a customer-facing API (Node.js 22.x, Fastify, Postgres 16, Redis 7), I realized I had a pretty embarrassing gap: zero load testing.

We had 340 unit tests. We had integration tests. We had a CI pipeline that ran in under ten minutes. What we did not have was any idea what happened when 200 people hit the checkout endpoint at the same time.

I knew the theory. Ramp up virtual users, watch p95 latency, look for the cliff. But every time I sat down to write a k6 script, I hit the same wall: the API had 38 endpoints, most of them needed auth tokens and realistic payloads, and I did not want to hand-write 38 scenario files with fake data that looked nothing like production traffic.

The constraint that made this interesting: I had about one working day to spend on it. Anything longer and it would eat into the launch buffer.

So I decided to run an experiment. I would give Claude Code (v2.1, running in the terminal) the OpenAPI spec and the route handlers, and let it own the load-testing work end to end. My job would be to review, run, and interpret.

How I Solved It

Step 1: Feed it the shape of the traffic, not just the spec

My first attempt was lazy. I pointed Claude Code at openapi.yaml and said "write k6 load tests for every endpoint." It produced 38 files that each hammered one endpoint with a fixed payload. Technically correct, practically useless. Real users don't call GET /orders/{id} in isolation; they log in, browse, add items, and check out.

So I backed up and gave it context about the actual user journeys. Here is the prompt that worked:

Here are the 3 user journeys that matter for launch, in order of business impact:

1. Browse → view product → add to cart → checkout (60% of traffic)
2. Login → view order history → view single order (30%)
3. Admin: list orders with filters, paginated (10%)

Write a k6 test suite that:
- Models these as 3 scenarios with the traffic split above
- Reuses the auth token per virtual user (login once, not per request)
- Generates realistic payloads from the Zod schemas in src/schemas/
- Ramps from 0 to 300 VUs over 5 minutes, holds 10 minutes, ramps down
- Fails if p95 > 500ms or error rate > 1% on any scenario

Don't write one file per endpoint. Organize by journey.
Enter fullscreen mode Exit fullscreen mode

The difference between "here's the spec" and "here's who uses this and how" was enormous. The second version produced a suite I actually wanted to run.

Step 2: The k6 structure it generated

It landed on a layout I have kept since:

loadtest/
├── config.js          # thresholds, stages, base URL
├── lib/
│   ├── auth.js        # login once per VU, cache token
│   └── factories.js   # payload generators derived from Zod schemas
├── scenarios/
│   ├── shopper.js
│   ├── returning-customer.js
│   └── admin.js
└── main.js            # wires scenarios + traffic split
Enter fullscreen mode Exit fullscreen mode

The load-bearing part is main.js. Here is the trimmed version:

import { shopper } from './scenarios/shopper.js';
import { returningCustomer } from './scenarios/returning-customer.js';
import { admin } from './scenarios/admin.js';

export const options = {
  scenarios: {
    shopper: {
      executor: 'ramping-vus',
      exec: 'shopper',
      startVUs: 0,
      stages: [
        { duration: '5m', target: 180 },
        { duration: '10m', target: 180 },
        { duration: '2m', target: 0 },
      ],
    },
    returning: {
      executor: 'ramping-vus',
      exec: 'returningCustomer',
      startVUs: 0,
      stages: [
        { duration: '5m', target: 90 },
        { duration: '10m', target: 90 },
        { duration: '2m', target: 0 },
      ],
    },
    admin: {
      executor: 'constant-vus',
      exec: 'admin',
      vus: 30,
      duration: '17m',
    },
  },
  thresholds: {
    'http_req_duration{scenario:shopper}': ['p(95)<500'],
    'http_req_duration{scenario:returning}': ['p(95)<500'],
    'http_req_duration{scenario:admin}': ['p(95)<800'],
    'http_req_failed': ['rate<0.01'],
  },
};

export { shopper, returningCustomer, admin };
Enter fullscreen mode Exit fullscreen mode

Two things I would not have thought to do on my own:

  • Per-scenario thresholds using tags, so a slow admin endpoint doesn't mask a fast checkout path (or vice versa)
  • Splitting the 300 VUs across scenarios to match the 60/30/10 traffic mix, instead of one big pool that picks randomly

The factories file was the other clever bit. Instead of hardcoding fake data, it imported the same Zod schemas the API uses for validation and generated payloads that would always pass. When I later added a required field to the checkout schema, the load test picked it up with zero changes.

Step 3: Run it against staging and watch things break

I ran it with k6 v1.1 against a staging environment sized identically to production (2 API replicas, 1 Postgres instance, 1 Redis).

k6 run loadtest/main.js --out json=results.json
Enter fullscreen mode Exit fullscreen mode

First run: failed at 140 VUs. Not 300. Not even half.

This is where the workflow got interesting. I did not want to guess at the cause, so I pasted the k6 summary output and the API logs from the same window into Claude Code and asked it to correlate. Here is what it found, in the order it found them.

Bottleneck 1: The database pool was sized for a laptop

graph LR
  A[300 VUs] --> B[2 API replicas]
  B --> C[Pool: 10 conns each]
  C --> D[(Postgres: max 100)]
  style C fill:#f96,stroke:#333

The Postgres client pool was set to its default of 10 connections per replica. With two replicas, that is 20 concurrent queries max. Once more than about 20 requests needed the DB at the same time, everything else queued. The p95 went from 80ms at 100 VUs to 2,400ms at 140 VUs. Classic cliff.

Claude Code spotted it from the log pattern: timeout exceeded when trying to connect spiking exactly when latency did. Fix was a one-liner in the pool config, bumped to 40 per replica, still comfortably under the Postgres limit. That alone got us to 220 VUs.

Bottleneck 2: An N+1 hiding on one path

At 220 VUs the returning-customer scenario started failing thresholds while shopper stayed fine. That asymmetry was the clue.

The order history endpoint used a batching layer to load line items. The single-order endpoint did not. It looked like this:

// Before: one query per line item 😬
const order = await db.orders.findById(id);
for (const item of order.itemIds) {
  item.product = await db.products.findById(item.productId);
}
Enter fullscreen mode Exit fullscreen mode

One order with 12 line items meant 13 queries. Under load, that path alone was generating more DB traffic than the entire shopper journey. Claude Code proposed the fix and wrote it:

// After: one query, keyed lookup
const order = await db.orders.findById(id);
const products = await db.products.findByIds(order.itemIds.map(i => i.productId));
const byId = new Map(products.map(p => [p.id, p]));
order.items = order.itemIds.map(i => ({ ...i, product: byId.get(i.productId) }));
Enter fullscreen mode Exit fullscreen mode

I had reviewed that file before. I had tests for that file. None of that caught it, because a 13-query endpoint is fast when one person calls it.

Bottleneck 3: Logging was eating CPU

The last one was the sneakiest. At 280 VUs, p95 crept up again, but the DB was fine and Redis was fine. CPU on the API replicas was pinned at 100%.

Claude Code asked me to run a 30-second CPU profile during load. The top frame was JSON.stringify inside the request logger. Someone (me, six months ago) had added a log line that serialized the full request body at info level for "debugging." On a checkout request with a 40-item cart, that was serializing several kilobytes per request, 200 times a second.

// Before
logger.info({ body: req.body }, 'incoming request');

// After
logger.info({ path: req.url, bodyBytes: req.headers['content-length'] }, 'incoming request');
Enter fullscreen mode Exit fullscreen mode

Removing it dropped CPU to 60% at the same load and bought us the last 20 VUs of headroom.

The final run

After all three fixes:

Metric Before After
Max VUs before threshold failure 140 300+ (target hit)
p95 latency at 300 VUs n/a (failed) 310ms
Error rate at 300 VUs 11% 0.2%
Wall-clock time spent 0 ~6 hours

Six hours, including the runs themselves. Three bugs that would have surfaced on launch day in front of real customers.

Lessons Learned

1. The agent is only as good as your traffic model

"Write load tests for this API" gets you noise. "Here are the three journeys and their traffic split" gets you a real test. The single most important input was not the spec; it was me writing down who uses the system and in what order. If you can't describe your traffic in three bullet points, do that before touching an agent.

2. Derive payloads from your validation schemas, not from fixtures

Letting the factories import the Zod schemas meant the load test could never drift out of sync with the API. This is one of those things an agent will suggest if you ask "how do we keep this from rotting," and it is worth asking every time.

3. Per-scenario thresholds beat global ones

If I had used a single global p95 threshold, the N+1 on the order-detail path would have been averaged away by the fast shopper traffic. Tag your scenarios and set thresholds per tag. This was the difference between finding bug 2 and shipping it.

4. Let the agent correlate logs and metrics, but you run the profiler

Claude Code was excellent at reading a k6 summary next to a log excerpt and saying "these two spikes line up, here is the likely cause." It could not run the CPU profile for me on staging, and I would not want it to. The division of labor that worked: the agent does the pattern matching and proposes the fix, I execute anything that touches a live environment.

5. Load tests find the bugs your unit tests are structurally blind to

All three bottlenecks were invisible at concurrency 1. Pool sizing, N+1s, and hot-path serialization cost only show up under load. If your test pyramid has no load layer, you have a category of bugs you are guaranteed to find in production. An agent makes that layer cheap enough that "no time" stops being an excuse.

What's Next

  • Wire it into CI on a nightly schedule against staging, with results posted to a channel. A daily p95 trend line catches regressions before anyone notices.
  • Add a soak test (2 hours at 60% of peak) to hunt memory leaks. The 17-minute run is too short to catch slow growth.
  • Teach the agent to read the flame graph. Right now I run the profiler and paste the top frames. I want to try feeding it the raw profile output and see if it can find the hot path without my summary.

Wrap-up

If you have a launch coming and no load tests, block one afternoon, write your three traffic journeys on paper, and hand them to Claude Code with your schemas. You will probably find something. I found three somethings.

If this was useful, follow me here on Dev.to for more build logs like this one. I write about running AI coding agents on real production work, including the parts that go wrong. And if you have found a bottleneck under load that no unit test could have caught, I would love to hear about it in the comments. 🚀

Top comments (2)

Collapse
 
dev_supports profile image
DEV SUPPORTS •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

‍​ ‍

Collapse
 
unitbuilds profile image
UnitBuilds •

Do not follow any external links! DEV.to uses Sloan for automated messages, this is likely phishing.