Load Testing Before Launch: Find the Ceiling Before Your Users Do

A practical approach to load testing a web app before launch: pick the right scenarios, run k6 against staging, read the numbers that matter, and decide what to fix versus what to leave alone.

Load Testing Before Launch: Find the Ceiling Before Your Users Do

Most web apps meet their real traffic for the first time on launch day. That's an expensive way to find out the checkout dies at 40 concurrent users, or that the queue worker can't keep up with the signup burst. A load test an afternoon before launch doesn't guarantee nothing breaks, but it replaces "we have no idea" with a number and a list of known limits. Here's how we run tests for Laravel and Vue projects without turning it into a science project.

Test scenarios, not URLs

Hammering the homepage tells you almost nothing. The pages that melt under load are the ones doing real work: search with filters, report exports, checkout, anything that touches third-party APIs. A useful test plan has three scenarios, and they should come from your analytics or your client's honest description of a busy day, not from a sitemap crawl:

  • The peak flow. What does the busiest real moment look like? For a store it's the checkout path; for a dashboard it's the morning login wave hitting the main report.
  • The expensive endpoint. The one heavy query or export your app is known for. This is usually where the database shows its true character.
  • The background pressure. Queue volume during the peak: emails, webhooks, image processing. A web server can look healthy while the queue backlog grows by thousands per minute.

Write these as scripts with realistic think time (a user reading a page for 5-10 seconds between clicks) rather than a machine-gun of requests. Without pauses, you're testing a different application than the one your users visit.

k6 in practice

We use k6 for this. It's scriptable in JavaScript, runs from a laptop or a CI job, and reports percentiles instead of just averages:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    stages: [
        { duration: '2m', target: 50 },
        { duration: '5m', target: 50 },
        { duration: '2m', target: 0 },
    ],
    thresholds: {
        http_req_duration: ['p(95)<500'],
        http_req_failed: ['rate<0.01'],
    },
};

export default function () {
    const res = http.get('https://staging.example.com/report');
    check(res, { 'status is 200': (r) => r.status === 200 });
    sleep(Math.random() * 8 + 3);
}

The ramp-up matters more than the peak. A sudden wall of 500 virtual users at second one hides the interesting behavior: at which concurrency does latency start climbing, and does it climb gradually or fall off a cliff? Staged ramps (stages) give you the knee of the curve, which is the actual answer to "how many users can this box take".

Read percentiles, not averages

An average response time of 200ms says nothing when one in twenty requests takes 8 seconds and that one happens to be the payment confirmation. Watch three numbers:

  • p95 and p99 latency. If p95 is fine but p99 is a disaster, the app has an intermittent stall worth hunting (lock contention, a slow external call without a timeout, garbage collection pauses at PHP-FPM level).
  • Error rate over time. Errors that appear only at the peak usually mean connection limits: database max_connections, PHP-FPM workers, or the connection pool between them.
  • Queue depth and DB connections. Two graphs that forecast trouble before user-facing latency moves.

Also watch the server itself during the run. CPU pegged at 100% with acceptable latency means you found the ceiling and it's fine for now. Memory climbing steadily without returning means a leak, and that never gets better on its own.

The infrastructure surprises

The findings that surprise teams are rarely the app code:

  • PHP-FPM worker count sets your hard ceiling for concurrent requests. Every slow request holds a worker, and one slow endpoint (a report that takes 4 seconds) can exhaust all workers and make the whole site queue, including the homepage.
  • The database is usually the first wall. Missing indexes that cost 10ms with 1,000 rows cost 2 seconds with 500,000. Load tests are how an "it's fine locally" query gets caught before it makes the launch morning memorable.
  • Staging is not production unless you make it so. Test at least the same class of machine, with realistic data volume. A table with 50 test rows will pass anything; the production table with three years of real data is what will fail.

One more thing people forget: run the test against HTTPS through the real nginx stack, not against the app server directly. TLS handshakes and proxy buffering are part of the cost your users pay.

Fix what matters, then stop

Load testing rewards a specific kind of restraint. When the numbers come back, sort problems into three buckets: fix before launch (errors, timeouts, anything blocking the peak flow), fix soon (slow endpoints that degrade gracefully), and document (the known ceiling, written down next to the monitoring thresholds so an alert at "80% of tested capacity" actually means something).

Then re-run the scenario once after the fixes. A before/after pair of numbers beats any amount of theorizing, and the final run doubles as proof for the client that the launch numbers were checked, not hoped for.