All posts
5 min read

Load Testing Your App Before Launch Day

Find out where your app breaks before your users do. What load, stress, spike, and soak tests reveal, writing a realistic k6 scenario with thresholds, reading p95 and error rates, finding the actual bottleneck, and the safety rules for testing without taking production — or a third-party API — down.

infrastructuredebuggingdeploymentintermediate

Your app is fast with you clicking around. Launch day brings 500 people in ten minutes, and something you never exercised — a connection pool, an unindexed query, a memory leak, a rate limit on an upstream API — falls over. A load test is a rehearsal: simulated traffic, in a safe environment, to find the first thing that breaks while you still have time to fix it.

Kinds of test

Test Shape Answers
Load Ramp to expected peak, hold Do we meet our latency targets at expected traffic?
Stress Keep ramping past peak Where's the breaking point, and how does it fail?
Spike Sudden jump (launch, newsletter, viral post) Do we survive a burst? Does autoscaling or queuing cope?
Soak Moderate load for hours Leaks, connection exhaustion, disk filling, slow degradation

Start with a load test at 2–3× your realistic peak. Then a spike test if a launch is coming.

Write a realistic scenario

Hammering the homepage proves little — it's probably cached. Model what users actually do, with think time between steps:

// load.js — run with: k6 run load.js
import http from "k6/http"
import { check, sleep, group } from "k6"

export const options = {
  scenarios: {
    browse_and_buy: {
      executor: "ramping-vus",
      stages: [
        { duration: "2m", target: 50 },
        { duration: "5m", target: 200 },
        { duration: "2m", target: 0 },
      ],
    },
  },
  thresholds: {
    http_req_failed: ["rate<0.01"],                 // <1% errors
    "http_req_duration{name:list}": ["p(95)<400"],  // ms
    "http_req_duration{name:checkout}": ["p(95)<800"],
  },
}

const BASE = __ENV.BASE_URL

export default function () {
  group("browse", () => {
    const r = http.get(`${BASE}/api/products?limit=20`, { tags: { name: "list" } })
    check(r, { "list 200": (res) => res.status === 200 })
    sleep(Math.random() * 3 + 1)
  })

  if (Math.random() < 0.1) {
    group("checkout", () => {
      const r = http.post(`${BASE}/api/checkout`, JSON.stringify({ productId: "test-sku", qty: 1 }), {
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${__ENV.TEST_TOKEN}` },
        tags: { name: "checkout" },
      })
      check(r, { "checkout ok": (res) => res.status === 201 })
    })
  }
}

k6 is shown here; Locust (Python), Artillery, Gatling, and JMeter are common alternatives.

Realism checklist:

  • Traffic mix — mostly reads, some writes, a few expensive operations, in realistic proportions.
  • Think time — real users pause; without it, 200 virtual users behave like thousands.
  • Varied data — different products, users, and search terms. Querying the same ID repeatedly tests your cache, not your database.
  • Authenticated flows — pre-create test users and tokens; don't load-test your sign-up and email sending by accident.
  • Realistic data volume — a database with 50 rows hides every missing index. Seed production-like volumes.

Read the results properly

  • Percentiles, not averages. An average of 120 ms can hide a p99 of 6 seconds. Watch p95/p99 per endpoint.
  • Error rate — including timeouts, 5xx, and 429s.
  • Throughput — requests per second actually served. When throughput stops rising as load rises, you've found saturation.
  • Latency vs load curve — flat, then a knee. The knee is your practical capacity.

And watch the server side at the same time: CPU, memory, event-loop lag, database connections in use, slow query log, and any upstream API latency. The load tool tells you that it's slow; server metrics tell you why. (Structured Logging and How to Know When Your App Is Down.)

Usual bottlenecks, in rough order of frequency

  1. Missing indexes / slow queries — database CPU climbs, one query dominates pg_stat_statements. (Database Indexes.)
  2. N+1 queries — query count per request scales with page size. (The N+1 Query Problem.)
  3. Connection pool exhaustion — requests queue waiting for a database connection; errors like "timeout acquiring connection." (Postgres Connection Pooling.)
  4. CPU-bound work on the request path — image processing, PDF generation, big JSON serialisation blocking a Node event loop. Move it to a worker. (Your App Needs Background Jobs.)
  5. Memory limits — OOM kills under load (exit code 137). (How Container CPU and Memory Limits Actually Work.)
  6. Upstream limits — your AI provider, email service, or payment API rate-limits you. Queue, batch, cache, or degrade gracefully.
  7. Cache stampedes — a popular cache key expires and hundreds of requests rebuild it at once. (Preventing Cache Stampedes.)

Fix one bottleneck, re-run the same test, compare. The next bottleneck will appear; stop when you're comfortably above your target.

Safety rules

  • Test staging, not production — a staging environment with the same instance sizes and a production-sized (anonymised) dataset. (Dev, Staging, and Production Explained.)
  • Never load-test third parties. Stub or mock payment, email, SMS, and AI APIs, or use their sandbox with care — hammering them can breach their terms and get your account suspended, and real AI calls cost real money.
  • Check your provider's policy before generating heavy traffic against hosted infrastructure; some require notice, and your own DDoS protection may block you.
  • Label the traffic (a header or user agent) so it's easy to filter from analytics and logs.
  • Have a kill switch — know how to stop the test instantly.

Before launch: the minimum

  • Realistic scenario at 2–3× expected peak passes thresholds (p95 and error rate)
  • Spike test survives a sudden burst without cascading failure
  • One-hour soak shows no memory growth or connection leak
  • Server metrics recorded during tests; the bottleneck at the breaking point is known
  • Third-party calls stubbed; staging sized like production

EasySpawn gives each project a persistent workspace with its app and managed database side by side, so Claude Code can seed realistic data, run a k6 scenario, and read the slow query log in one place — and preview deployments on Pro give you a safe target to test against. See pricing or join the waitlist.

Related: Why Is My Website Slow? · Launch Your First App · Test Your App Before Launch

Keep reading