DevOps ·

Load Testing for Startups: Finding Breaking Points Before Your Users Do

Most startups skip load testing until the first traffic spike breaks production. This guide covers the three test types, tool selection, realistic k6 scripts in TypeScript, and integrating load tests as deploy gates.

Load Testing for Startups: Finding Breaking Points Before Your Users Do

Most startups skip load testing until the first traffic spike breaks production. Then someone pastes a Slack message saying “the site is down” and everyone scrambles to figure out what the ceiling actually was. At a larger company with redundant infrastructure and multiple instances, the blast radius is contained. At a startup with a single Postgres instance and an autoscaling group that has never actually autoscaled, it is not.

The margin for error is smaller at small scale, not larger. This guide covers how to get load testing right before your users get to do it for you.

The Three Test Types Worth Running

Not all load tests ask the same question. Conflating them leads to incomplete coverage.

Baseline tests reproduce normal, expected traffic. They answer: “Does the system handle what it sees every day with acceptable latency?” These run fast (5-10 minutes), and you should run them on every deploy.

Stress tests push traffic above baseline until something breaks. They answer: “Where is the ceiling, and what breaks first?” These take 20-40 minutes and you run them before a launch, a major feature, or a planned traffic event.

Soak tests run at moderate load for a long time (hours). They answer: “Does the system degrade over time?” Memory leaks, connection pool exhaustion, file descriptor leaks, and disk fill-ups only appear here. Run soak tests weekly or before any release that changes long-running processes.

If you only have time for one, do baseline tests on every deploy and a stress test before any event you are planning to promote. Soak tests reveal a different class of failure and should not be skipped indefinitely.

Tool Comparison

ToolLanguageStrengthsWeaknesses
k6JavaScript / TypeScriptFast, scriptable, CI-native, Grafana ecosystemNo browser automation in open-source
ArtilleryYAML / JSQuick YAML scenarios, good for REST APIsLess composable for complex flows
LocustPythonPythonic, good for teams already on PythonHigher resource overhead per VU
Grafana k6 CloudJS / TSMulti-region, managed infra, built-in resultsPaid, overkill early on

For most TypeScript-heavy teams, k6 is the right call. It runs via a single binary, integrates into GitHub Actions with one step, uses the JavaScript module system, and outputs metrics in a format Grafana can consume directly. The rest of this article uses k6.

Writing Effective k6 Scripts in TypeScript

k6 does not run Node.js. It uses a V8-based runtime that supports ES modules but not Node built-ins. Think of it as a browser-like JS environment with a k6 HTTP client injected.

A test script defines a default export function that represents one virtual user (VU) executing one iteration of a scenario. k6 manages concurrency by running many VUs simultaneously.

A Realistic User Flow

Avoid testing a single endpoint in isolation. Production traffic has shape. Users log in, browse, submit a form, and log out. Correlate session tokens across requests.

import http from "k6/http";
import { check, sleep } from "k6";
import { SharedArray } from "k6/data";

// Load test users from a JSON fixture so each VU gets a unique credential.
const users = new SharedArray("users", () =>
  JSON.parse(open("./fixtures/users.json"))
);

export const options = {
  stages: [
    { duration: "1m", target: 50 },   // ramp up to 50 VUs
    { duration: "3m", target: 50 },   // hold at 50 VUs
    { duration: "1m", target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ["p(99)<500"],  // 99th percentile under 500ms
    http_req_failed: ["rate<0.01"],    // error rate under 1%
  },
};

const BASE_URL = __ENV.BASE_URL || "https://api.staging.example.com";

export default function () {
  const user = users[__VU % users.length];

  // Step 1: Login and extract the session token.
  const loginRes = http.post(
    `${BASE_URL}/auth/login`,
    JSON.stringify({ email: user.email, password: user.password }),
    { headers: { "Content-Type": "application/json" } }
  );

  check(loginRes, {
    "login succeeded": (r) => r.status === 200,
    "token present": (r) => r.json("token") !== undefined,
  });

  if (loginRes.status !== 200) return;

  const token = loginRes.json("token") as string;
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  };

  // Think time: simulate the user reading something before the next action.
  sleep(Math.random() * 2 + 1); // 1-3 seconds

  // Step 2: Fetch a resource list.
  const listRes = http.get(`${BASE_URL}/projects`, { headers });
  check(listRes, { "list returned 200": (r) => r.status === 200 });

  sleep(Math.random() * 3 + 1);

  // Step 3: Create a resource.
  const createRes = http.post(
    `${BASE_URL}/projects`,
    JSON.stringify({ name: `Test Project ${__VU}-${__ITER}`, status: "active" }),
    { headers }
  );

  check(createRes, {
    "create returned 201": (r) => r.status === 201,
    "id present": (r) => r.json("id") !== undefined,
  });

  sleep(1);
}

Key points in this script: SharedArray loads fixture data once per process, not once per VU. sleep() adds think time so you are not generating a perfectly synchronized wall of requests that no real user would produce. __VU and __ITER are built-in k6 globals you can use to generate unique data per virtual user per iteration.

Key Metrics to Watch

p99 latency is the one number that tells you how bad things get for the slowest 1% of requests. Average latency hides tail behavior. A system with p50=80ms and p99=4000ms is not performing well.

Error rate is your smoke detector. During baseline tests it should be zero. During stress tests watch where it starts climbing. A jump from 0% to 5% error rate at 200 concurrent users tells you the ceiling is somewhere around there.

Throughput ceiling is requests per second at which latency starts degrading non-linearly. Plot RPS against p99 latency. The point where the curve bends is your practical ceiling, not the point where the server crashes.

Resource saturation covers CPU, memory, open file descriptors, and network connections on the host. Latency degradation without obvious CPU spike usually means connection pool exhaustion or lock contention.

Testing Serverless: Cloudflare Workers and Lambda

Serverless has a different failure mode than long-running servers. There is no connection pool to exhaust in the traditional sense, but there are concurrency limits, cold start latency, and queue depth limits on async triggers.

Cold starts appear in your p99 when a new instance initializes. On Lambda they range from 100ms (Node.js, small deployment package) to several seconds (Java with a fat JAR). On Cloudflare Workers they are typically under 5ms because Workers do not spin up a full VM. In your k6 thresholds, a single p99 outlier from cold start can fail your gate. Use a warmup stage.

export const options = {
  stages: [
    // Warmup: low traffic to pre-warm instances.
    { duration: "30s", target: 5 },
    // Measurement window: exclude warmup from threshold evaluation.
    { duration: "3m", target: 100 },
    { duration: "30s", target: 0 },
  ],
  // Apply thresholds only after warmup by using scenarios with startTime.
};

Concurrency limits on Lambda default to 1000 per region per account. At 1000 concurrent VUs you will hit TooManyRequestsException. Test this explicitly if you expect spiky traffic. The solution is reserved concurrency for critical functions and provisioned concurrency for predictable high-traffic paths.

For queue-driven architectures (SQS, EventBridge Pipes), load testing the producer is only half the picture. Measure queue depth under load and time-to-process. A consumer that keeps up at 100 messages/second may fall behind at 1000.

Testing Databases: Connection Pool Exhaustion

This is where most startup production incidents actually originate. Postgres defaults to max_connections=100. With PgBouncer in transaction mode you can multiplex thousands of app connections onto that pool. Without it, 100 app server threads each holding an open connection will hit the ceiling before you hit significant request volume.

Under load testing, watch for:

  • FATAL: remaining connection slots are reserved for non-replication superuser connections in your server logs
  • Latency spikes that correlate with connection wait time, not query execution time
  • Queries that are fast in isolation but slow under concurrent load due to lock contention on hot rows

Run EXPLAIN ANALYZE on your slowest queries in isolation first, then verify they stay fast at your expected concurrency. Queries without indexes on filter columns that are cheap at 100 rows become expensive at 10 million rows and are invisible in local testing.

A minimal check for connection pool saturation in your k6 script:

check(response, {
  "no db error": (r) =>
    !r.body.toString().includes("remaining connection slots"),
});

Not elegant, but it catches the failure before your Sentry quota fills up.

CI/CD Integration: Load Tests as Deploy Gates

Baseline tests belong in your deploy pipeline. Stress tests are too slow for every deploy but should gate releases to production for high-traffic services.

Here is a minimal GitHub Actions job using k6:

# .github/workflows/load-test.yml
name: Load Test

on:
  push:
    branches: [main]

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run k6 baseline test
        uses: grafana/k6-action@v0.3.0
        with:
          filename: tests/load/baseline.ts
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

The thresholds block in your k6 script controls the exit code. If thresholds are breached, k6 exits with code 99, which fails the CI step. Keep the baseline test short (5 minutes total with ramp) so it does not become a bottleneck in your pipeline.

For trend tracking over time, pipe results to Grafana Cloud or a self-hosted InfluxDB instance:

k6 run --out influxdb=http://localhost:8086/k6 baseline.ts

Watching p99 trend across deploys catches gradual regressions before they become incidents.

Common Mistakes

Testing from a single region. Your CDN and DNS routing look nothing like what a single-origin test sees. If you are testing a globally distributed system, either use a multi-region managed tool or accept that single-region results are a lower bound.

Ignoring the warmup period. Cold infrastructure, JIT compilation, and cache fill-up all distort early measurements. Start collecting metrics after a warmup stage.

Unrealistic scenarios. Hitting /health 1000 times per second proves your health check endpoint is fast. It says nothing about what happens when 1000 users hit your checkout flow simultaneously. Map your test scenarios to actual user behavior using access logs.

Not testing your dependencies. Load testing your API while your third-party payment provider or shipping rate calculator has a 2-second p99 is optimistic. Either mock those dependencies in load tests or accept you are not measuring the full system.

Running tests against production without isolation. Load testing production is occasionally necessary, but it requires careful scoping. Use a dedicated test account, avoid writes to shared tables, and coordinate with your on-call rotation. Never load test production without a kill switch.

A Starter k6 Template

// tests/load/baseline.ts
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "1m", target: 20 },   // ramp up
    { duration: "3m", target: 20 },   // hold
    { duration: "1m", target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ["p(99)<500"],
    http_req_failed: ["rate<0.01"],
  },
};

const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";

export default function () {
  const res = http.get(`${BASE_URL}/api/health`);

  check(res, {
    "status 200": (r) => r.status === 200,
    "body present": (r) => r.body.length > 0,
  });

  sleep(1);
}

This is deliberately minimal. Add authentication, multi-step flows, and data correlation as you understand your own traffic shape better. The goal is a working gate you can run immediately, not a perfect simulation on day one.

Tradeoffs: When to Invest More

ScenarioRecommended investment
Pre-launch with no traffic dataStress test to find ceiling, soak test overnight
Every deploy on a mature serviceAutomated baseline test as CI gate
Traffic event (Product Hunt, press)Full stress + soak before the event, monitoring during
Serverless-only architectureFocus on concurrency limits and cold start p99
Monolith with single DBPrioritize connection pool and slow query testing

The answer to “how much load testing is enough” is: enough to know where your ceiling is and enough confidence that normal traffic does not approach it. That is a different answer for a side project running on a hobby tier versus a Series A company with paying customers expecting uptime.

Finding your breaking point before your users do is not about running sophisticated tooling. It is about running any tooling at all, consistently, before you need it.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.