Load Testing a Next.js BFF: Why We Moved from Postman to k6
I started load testing our Next.js backend-for-frontend in Postman. I finished in k6. Here's why I switched mid-project, what Claude did for me along the way, and the one refactor that turned a pile of response times into numbers leadership actually asks for.
We have a Next.js application that does almost nothing on its own. It's a backend-for-frontend: it renders the UI and orchestrates calls, but every piece of real data — customer records, entitlements, transactions — comes from an API layer built in MuleSoft. Two systems, one number the user actually feels.
I started load testing that stack in Postman. I finished in k6. This is the honest accounting of why I switched mid-project, what Claude did for me along the way, and the one refactor that turned a pile of response times into numbers our leadership now asks for by name.
Why I Started in Postman
Postman was the obvious first move, and I'd defend the choice again.
The API team already had collections. Those collections were the real source of truth for request shapes, headers, and auth flows — not a wiki page, not a Confluence diagram, the actual working requests. Starting anywhere else meant re-deriving all of that by hand.
Postman's performance testing view is also genuinely good for what it is. You pick a collection, set virtual users and a duration, hit run, and watch response times and error rates plot in real time. Within an hour I had load curves for the API layer and had already found one endpoint that fell over much earlier than anyone expected. That's a real result, delivered same-day, with zero new tooling for the team to learn.
And it automates. The Postman CLI can run collections in a pipeline, so the "this only lives on Shane's laptop" objection has an answer.
For the API layer in isolation, Postman did the job.
Where It Stopped Working
The trouble started when I pointed at the Next.js app instead of the APIs behind it.
Postman thinks in requests. A BFF page load is a fan-out.
When a user opens a dashboard in our app, one navigation turns into a server-rendered document plus a handful of client-side fetches to Next.js route handlers, each of which turns around and calls one or more Mule endpoints. Some of those happen in sequence because one response feeds the next. Some happen in parallel. The user doesn't experience any of them individually — they experience "the page took four seconds."
A collection run gives you a p95 per request. That is the wrong unit. You can have every single request comfortably under its threshold and still have a page that feels broken, because the slow ones are chained. Nothing in a flat collection run captures the shape of that chain.
I needed the load generated somewhere specific.
We wanted to run tests from inside our own infrastructure, close to the data center the app runs in, so the numbers reflected the application rather than the public internet between it and my desk. Postman's performance runner generates load from wherever Postman is running, which in practice meant a laptop.
The tests weren't code.
I couldn't send a collection through pull request review. I couldn't diff it. I couldn't ask a teammate "why did the threshold on this journey change last sprint" and get an answer from git.
That third one is what actually made the decision. I wasn't building a one-time measurement, I was building a thing the team would own.
Why k6
k6 is an open-source load testing tool from Grafana Labs. Tests are written in JavaScript. The runner itself is a single Go binary.
That combination is the pitch: engineers write tests in a language they already use every day, and infrastructure runs them anywhere a static binary or a container can go. No desktop app in the loop.
Three properties mattered for us specifically:
It's a file in the repo. Tests live next to the application code, go through normal review, and carry their history in git. When a threshold loosens, there's a commit and a reason.
It runs where you put it. Our AI infrastructure engineer picked the suite up out of source control and stood it up inside our environment. Nothing about the tests had to change for that to work — same scripts, different host.
Thresholds are pass/fail. k6 lets you declare your performance targets in the test itself, and a violated threshold means a non-zero exit code. That turns "the performance numbers" from a report someone reads into a gate that either opens or doesn't.
Letting Claude Write the First Pass
I pointed Claude at both codebases — the Next.js app and the API layer — and this is where the time actually got saved.
The prompt that worked wasn't "write me k6 load tests." It was closer to:
Read this app. For each of these five user journeys, list every network call the browser makes and every downstream call the server makes. Tell me which calls are sequential because one depends on another, and which fire in parallel.
Sequence is the whole game in a load test. Get the fan-out wrong and you produce confident, precise, wrong numbers. And figuring out the fan-out by hand means reading route handlers, tracing server components, finding the fetches buried in client components, and cross-referencing all of it against the API layer. It's a day of tedium, it's easy to get wrong, and it's exactly the kind of work an agent with access to both repos is good at.
Once I had the call graph and had corrected the two places it was wrong, generating the actual k6 files was fast.
Here's the part worth being honest about: the first generated suite was strategically useless. It was a clean, well-organized set of tests that measured individual HTTP requests. Technically correct. Same flaw the Postman approach had.
That's when the real work started.
The Refactor That Mattered: Measure What the User Waits For
k6 gives you http_req_duration out of the box, and it's tempting to treat p95 of that as your answer. It isn't. Aggregated across an entire test it's an average over every request type you fired, which describes nothing that happens to an actual person.
We decided we wanted exactly two numbers per journey:
- Time to first screen — how long until the user sees something they can read.
- Time to fully loaded — how long until every component on the page has its data.
Those are custom metrics, so we declare them and time the journey ourselves:
import http from 'k6/http';
import { group, check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
// second arg marks these as time values, so k6 formats them as durations
const firstScreen = new Trend('journey_first_screen', true);
const fullyLoaded = new Trend('journey_fully_loaded', true);
const BASE = __ENV.BASE_URL;
export function accountDashboard(auth) {
group('account dashboard', () => {
const started = Date.now();
// 1. The server-rendered shell. First thing the user can actually see.
const shell = http.get(`${BASE}/accounts/dashboard`, {
headers: auth.headers,
tags: { journey: 'dashboard', step: 'shell' },
});
check(shell, { 'shell returned 200': (r) => r.status === 200 });
firstScreen.add(Date.now() - started, { journey: 'dashboard' });
// 2. The widgets the client fetches in parallel once the shell paints.
// http.batch fires these concurrently, the way a browser would.
const widgets = http.batch([
['GET', `${BASE}/api/balances`, null,
{ headers: auth.headers, tags: { journey: 'dashboard', step: 'balances' } }],
['GET', `${BASE}/api/transactions?limit=25`, null,
{ headers: auth.headers, tags: { journey: 'dashboard', step: 'transactions' } }],
['GET', `${BASE}/api/alerts`, null,
{ headers: auth.headers, tags: { journey: 'dashboard', step: 'alerts' } }],
]);
widgets.forEach((res) =>
check(res, { 'widget returned 200': (r) => r.status === 200 })
);
fullyLoaded.add(Date.now() - started, { journey: 'dashboard' });
// Real users read the page before clicking. Without this you've built a
// denial-of-service tool, not a load test.
sleep(Math.random() * 5 + 4);
});
}
Tagging each measurement with the journey name is what makes this readable later. One metric, sliced by journey, instead of a dozen near-identical metric names.
The shift is small in code and large in meaning. Before the refactor I could tell a director that our p95 response time was some number of milliseconds, and watch it land as noise. After it, I can say "at Monday-morning volume, 95% of users see the dashboard in under 1.8 seconds and have every widget populated in under 4" — and that sentence starts an actual conversation about whether that's good enough.
Claude was useful for the generation pass, but it was more useful for this pass. Reshaping a working suite so the output answers a different question is fiddly, repetitive editing across a dozen files. That's a good use of an agent.
Thresholds Are the Contract
Once the metrics describe user-visible waits, the thresholds become meaningful — they're service level objectives expressed as code:
export const options = {
scenarios: {
morning_peak: {
executor: 'ramping-arrival-rate', // model arrival rate, not VU count
startRate: 5,
timeUnit: '1s',
preAllocatedVUs: 100,
maxVUs: 500,
stages: [
{ target: 40, duration: '5m' }, // ramp in
{ target: 40, duration: '20m' }, // hold at expected peak
{ target: 120, duration: '5m' }, // push past it
{ target: 0, duration: '2m' }, // ramp out
],
exec: 'accountDashboard',
},
},
thresholds: {
'journey_first_screen{journey:dashboard}': ['p(95)<1800'],
'journey_fully_loaded{journey:dashboard}': ['p(95)<4000'],
'http_req_failed': [
{ threshold: 'rate<0.01', abortOnFail: true, delayAbortEval: '1m' },
],
},
};
Two things here are worth stealing.
ramping-arrival-rate is an open model: k6 starts new iterations at the rate you specify regardless of how the system is responding. Closed models, where a fixed pool of virtual users each waits for its response before looping, accidentally protect the system under test — as it slows down, your load goes down with it. Real traffic doesn't extend you that courtesy. Arrival rate is the honest model for user-facing traffic.
abortOnFail on the error rate stops the run when the application starts genuinely failing rather than burning twenty more minutes proving it. The delayAbortEval window keeps a brief startup blip from killing the test.
The Layout That Made It a Team Asset
Leaders and engineers want different things out of the same suite. Engineers want to run one journey against a local build. Leaders want a trend line. The directory structure is what lets both happen without anyone editing test code:
loadtests/
├── README.md # how to run it, what each suite means, who owns it
├── config/
│ ├── environments.json # base URLs and token endpoints per environment
│ └── profiles.js # named load profiles: smoke, baseline, peak, soak
├── lib/
│ ├── auth.js # token minting, done once in setup()
│ ├── metrics.js # the shared Trend definitions
│ └── data.js # test account pools, so no two VUs share a record
├── journeys/
│ ├── account-dashboard.js
│ ├── funds-transfer.js
│ └── statement-download.js
├── suites/
│ ├── smoke.js # 1 VU, every journey once — the PR gate
│ ├── daily-baseline.js # expected peak, scheduled nightly
│ ├── peak.js # 3x expected peak, run before releases
│ └── soak.js # moderate load for hours, to find leaks
└── reports/
Journeys are reusable functions. Suites compose them with a load profile. Adding a new journey doesn't touch a suite; changing how hard you push doesn't touch a journey.
The smoke suite deserves special mention. It's one virtual user running each journey once, it takes under a minute, and it runs on every pull request. It catches nothing about performance — it catches the far more common failure where someone renames a route and the load tests silently stop exercising the thing they claim to exercise. A load test suite that has quietly been testing 404s for three weeks is worse than no load test suite.
Making It Daily
The scheduled run streams metrics to Prometheus and writes a compact summary for humans:
K6_PROMETHEUS_RW_SERVER_URL="$METRICS_URL/api/v1/write" \
k6 run \
--out experimental-prometheus-rw \
--tag testid="baseline-$(date +%Y%m%d)" \
--tag build="$BUILD_ID" \
suites/daily-baseline.js
The testid tag is the convention Grafana's k6 dashboards key off, and it's what makes run-over-run comparison possible. Without it every run blurs into the same series.
For the summary, handleSummary runs once at the end of a test and can write whatever files you want:
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
const JOURNEYS = ['dashboard', 'transfer', 'statements'];
const p95 = (data, metric, journey) =>
data.metrics[`${metric}{journey:${journey}}`]?.values['p(95)'] ?? null;
export function handleSummary(data) {
const report = {
ranAt: new Date().toISOString(),
environment: __ENV.TARGET_ENV,
build: __ENV.BUILD_ID,
errorRate: data.metrics.http_req_failed.values.rate,
journeys: JOURNEYS.map((j) => ({
journey: j,
firstScreenP95Ms: p95(data, 'journey_first_screen', j),
fullyLoadedP95Ms: p95(data, 'journey_fully_loaded', j),
})),
};
return {
stdout: textSummary(data, { indent: ' ', enableColors: true }),
'reports/summary.json': JSON.stringify(report, null, 2),
};
}
One gotcha: tagged sub-metrics only show up in the summary data if something references them — a threshold or an explicit summaryTrendStats entry. If journey_first_screen{journey:dashboard} comes back undefined, that's usually why, not a typo.
That JSON is what feeds the daily digest. Nobody on the leadership side reads k6's console output, and they shouldn't have to. They get four numbers and a pass/fail.
What k6 Won't Tell You
Some of this I learned by being wrong first.
k6 is protocol-level, not a browser. It does not execute JavaScript, hydrate React, or paint anything. Our "first screen" metric is a server-side proxy for what the user sees — it measures when the document was available, not when it rendered. That distinction matters and I say it out loud every time I present the numbers. If you need true render timing, pair this with real user monitoring, or run k6's browser module over a small subset of journeys and accept that it costs far more resources per virtual user.
Generating load from inside the data center removes network variance. That's exactly what you want for regression detection and exactly what you don't want for absolute truth. Inside numbers are clean and comparable; they're also optimistic compared to what a user on hotel wifi experiences. We run the baseline internally and check it occasionally from outside to keep ourselves honest.
Caching will hand you fiction. Our first respectable-looking run was mostly warm cache hits on the same handful of test accounts. Once we pulled account IDs from a pool so virtual users stopped requesting identical data, the numbers got worse and got real.
Mint tokens once. Our early runs authenticated per iteration, which meant a meaningful share of the load was aimed at the identity provider. That's a load test of your IdP wearing a costume. Auth belongs in setup(), which runs once and hands its return value to every virtual user.
The ceiling is usually configuration. The first hard limit we hit wasn't application code at all — it was a connection pool sizing on the API layer. Traffic queued up behind a setting, not a slow query. That's a common and encouraging outcome: the fix was a config change, not a rewrite.
If You're Starting This Monday
- Write the smoke test first. One user, every journey, runs in a minute, gates every PR. It's the cheapest thing in the suite and it's what keeps the rest trustworthy.
- Get the call graph before you write a single assertion. What fires in parallel, what's chained. Point an agent at your codebase for this — it's exactly the kind of cross-file tracing that's tedious for a person and fast for a model, and you can verify its answer against your network tab.
- Name your metrics after what the user waits for. Not
http_req_duration. "Time to first screen." "Time to fully loaded." If a metric's name doesn't mean anything to your product owner, it won't survive contact with a leadership conversation. - Model arrival rate, not virtual users. Open model. Your traffic doesn't slow down out of politeness when your servers do.
- Commit it, then hand it to whoever owns infrastructure. The moment tests live in source control and run from somewhere permanent on a schedule, they stop being a project and start being a capability.
The Bottom Line
Postman wasn't the wrong choice — it was the right choice for testing an API layer, and it paid off inside of a day. It became the wrong choice when the thing under test stopped being a set of endpoints and became a user-facing application with a fan-out behind it.
k6 solved that because tests are code, the runner goes wherever you need it, and thresholds turn performance into a gate instead of a report. Using Claude to build the suite cut the initial work down substantially, but the bigger win was the second pass — reshaping the measurements until they described what a person waiting on a screen actually experiences.
We now get automated daily insight into how the application behaves at several load levels. When something regresses, we know within a day, we know which journey, and we know whether the user would have noticed. That's a very different place to be than "someone should load test this before the release."
Shane Larson is a software engineer and technical author based in Caswell Lakes, Alaska. He builds things at Grizzly Peak Software and has been arguing with editors — both human and artificial — for three decades.
