cd /news/ai-tools/how-i-let-claude-code-write-my-load-… Β· home β€Ί topics β€Ί ai-tools β€Ί article
[ARTICLE Β· art-140500] src=dev.to β†— pub= topic=ai-tools verified=true sentiment=↑ positive

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

A developer used Claude Code (v2.1) to generate a k6 load-testing suite for a Node.js 22.x/Fastify/Postgres 16/Redis 7 API two weeks before launch, organizing tests around three real user journeys rather than per-endpoint scripts. The AI-built suite surfaced three bottlenecks that the developer says would have taken the service down on day one, and the writeup shares the prompts, k6 configuration, and five lessons for performance testing with an AI coding agent.

by read9 min views2 publishedSep 27, 2026

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. ⚑

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.

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.

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.

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

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 };

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

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.

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

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.

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.

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);
}

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) }));

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.

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');

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

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.

"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.

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.

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.

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.

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.

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. πŸš€

── more in #ai-tools 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/how-i-let-claude-cod…] indexed:0 read:9min 2026-09-27 Β· β€”