---
title: "Chaos Engineering vs Load Testing: One Measures the Ceiling, the Other Measures the Floor"
description: "Both practices break your system on purpose, so they get filed under the same heading — and that is why a system can pass a load test at ten times its traffic and still be taken down the following week by one slow, non-critical service. A load test drives traffic up a ramp against a healthy system and finds the knee. A chaos experiment holds traffic at a normal Tuesday and breaks exactly one thing, to test the sentences written into your architecture diagram. Here is the real code for both, the hypothesis-blast-radius-abort discipline that separates an experiment from an outage, and how the same two questions apply to an LLM serving stack."
keywords: "chaos engineering, load testing, chaos engineering vs load testing, resilience testing, fault injection, blast radius, steady state hypothesis, p99 latency, capacity planning, SRE, game day, timeouts and retries, LLM serving load test, TTFT, system design"
created_at: "2026-08-18T15:30:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtu.be/8XbSdOmp1Vo" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">▶ The full 11-minute episode</a>
  <a href="https://youtube.com/shorts/5q1_MB8sYUE" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #0f172a; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">⚡ The 2-minute version</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

A service passes a load test at ten times its normal traffic. The report is green, the launch goes ahead — and the following week the whole checkout falls over, at ordinary Tuesday volume, because one non-critical downstream service got slow. Not down. **Slow.**

Nothing about that is a contradiction, and nothing about it is bad luck. The load test answered a question honestly. It just was not the question that took the system down.

Both practices in the title break your own system on purpose, which is why they end up filed under the same heading. They are opposites:

- A **load test** keeps the system healthy and **raises the traffic** until something bends. It finds a **ceiling**.
- A **chaos experiment** keeps the traffic normal and **breaks one thing on purpose**. It finds the **floor** — what still works when a part of the system does not.

> **Load testing validates your capacity plan. Chaos engineering validates your beliefs.** Those are not the same artefact, and passing one says nothing about the other.

---

## The subject has a name: resilience testing

The umbrella is resilience testing — deliberately putting a system under conditions it will eventually meet anyway, on a quiet weekday, while somebody is watching, instead of at 03:14 on a Sunday while a customer runs the experiment for you.

It covers load and stress testing, chaos experiments, and game days. It does **not** cover:

- **Unit and integration tests** — they check that the code does what it was written to do. Resilience testing asks what happens when something *else* stops doing that.
- **Monitoring** — it tells you what happened. It never chooses what to break.
- **A postmortem** — that is the same lesson, bought at retail price, from a customer.

The uncomfortable framing: for most systems in production right now, **nobody on the team knows how it fails.** Not through carelessness — because nobody has ever found out.

---

## The rope bridge

A rope bridge across a gorge: wooden planks, four ropes holding the whole thing up.

**The load test** walks people onto the bridge. Ten, then thirty, then fifty, watching the planks bow, until it can say: *this bridge holds forty-two people.* Every rope is intact the entire time. The answer is a number, and the number is a **weight limit**.

**The chaos experiment** keeps three people on the bridge — a normal load, nothing dramatic — and **cuts one of the four ropes on purpose**, because the drawing says four ropes and any three of them can hold the deck. That claim is written on the plan. It has never been executed. The experiment is an **audit of the drawing**, and the answer is not a number, it is either "the drawing was right" or "the drawing was wrong, and now we know it before it mattered".

Same bridge, same day, two questions that cannot substitute for each other.

---

## The cheap one first: a load test is a ramp

A load test is a staircase of synthetic traffic and a set of numbers you watch while it climbs. The important thing about this file is what is **absent** from it: nothing anywhere is broken.

```python
# loadtest/ramp.py  —  drive synthetic traffic up a staircase

STAGES = [                        # nothing is broken during ANY of this
    (   500, "2m"),               # warm the caches, fill the pools
    ( 5_000, "5m"),               # a busy normal day
    (50_000, "5m"),               # the number in the launch plan
]

def healthy(response, elapsed_ms):
    return response.status == 200 and elapsed_ms < 400

# what you record — never the average, it hides everything
report("p50",        percentile(latencies, 50))
report("p99",        percentile(latencies, 99))   # this one finds the knee
report("saturation", cpu, memory, db_connections_in_use)
```

Three details carry the whole practice.

**The stages ramp, they do not jump.** Starting at the target number measures your cold caches and your empty connection pools, not your system.

**The average is banned.** A mean latency of 120 ms is compatible with 95% of requests at 40 ms and 5% at 1.7 seconds — and the 5% is the part that generates support tickets. Percentiles, or nothing.

**Saturation is recorded next to latency**, because the useful output of a load test is not "it broke at 8,000". It is *which resource ran out first*.

### The knee

<table>
  <thead>
    <tr><th>requests / second</th><th>p50</th><th>p99</th><th>what is saturating</th></tr>
  </thead>
  <tbody>
    <tr><td>500</td><td>22 ms</td><td>88 ms</td><td>nothing — idle</td></tr>
    <tr><td>5,000</td><td>24 ms</td><td>91 ms</td><td>CPU ~40%, pool half used</td></tr>
    <tr><td>8,000</td><td>31 ms</td><td>340 ms</td><td>DB connections queueing ← the knee</td></tr>
    <tr><td>12,000</td><td>96 ms</td><td>2.9 s</td><td>everything waits on the pool</td></tr>
  </tbody>
</table>

Between 5,000 and 8,000 the p99 stops being flat and starts climbing faster than the traffic does. That bend is the answer. Notice that the p50 barely moves across the same range — a dashboard showing median latency would have called the third row healthy.

And the bend has a **cause**, not just a location: connections queueing. That distinction is what makes the result actionable. "We break at 8,000" is trivia. "We break at 8,000 because one pool of 40 connections is the binding constraint" is a work item.

---

## The honest limit of every load test

A load test measures **the system you have, under the traffic you imagined.**

Read that again slowly, because both halves are limits. The traffic is a guess about the future shaped like the past. And everything inside the system is **healthy by definition** for the duration of the run — every dependency answers, every disk is present, every node is up.

So a green load test licenses exactly one sentence: *at this traffic, with everything working, we are fine.*

The outage in the opening paragraph did not violate that sentence. It happened in the half the test never covered: **normal traffic, with something not working.**

---

## The other one: hold traffic normal, break one thing

Same system, opposite variable.

```python
# chaos/latency_experiment.py
# traffic stays at a normal Tuesday. exactly ONE thing changes.

HYPOTHESIS = (
    "If the payments call gets 300ms slower, checkout still answers "
    "in under one second — because that call has a timeout, and a fallback."
)

def steady_state():               # measured BEFORE the fault, not after
    return orders_per_minute() > 40 and checkout_p99_ms() < 1000

FAULT = inject_latency(
    target   = "payments.internal.example.com",
    delay_ms = 300,
    share    = 0.05,              # five percent of calls. not all of them.
)
```

The first thing in the file is not code. It is a **sentence** — a claim, in plain language, with a *because* attached. That structure is not decoration:

- A hypothesis makes the experiment **falsifiable**. Without it you are not testing, you are poking production and forming opinions afterwards.
- The `because` clause names the mechanism you are actually auditing — a timeout and a fallback. If the system survives for some *other* reason, you have learned something different from what you set out to learn, and you would never notice without the clause.

**`steady_state()` is measured first, and it is a business signal**, not a CPU graph. Orders per minute is the thing you care about; CPU is a proxy that can look fine through a total outage. Measuring it *before* the fault is what turns "the system is bad now" into "the system got worse *because of this*".

**`share = 0.05` is the blast radius.** Injecting 300 ms into every call is not an experiment, it is an outage you scheduled. Five percent is enough to observe and small enough that being wrong is survivable — and being wrong is a normal outcome here.

Note also what is *not* being injected: not 30 seconds, not a hard failure. **300 milliseconds of extra delay** on a call that already succeeds. Slow is a far more interesting fault than down, because down is the case people remember to handle.

---

## The trap: capacity plans and beliefs

This is the part that is routinely inverted.

Take any architecture diagram and read it not as a picture but as **a list of claims nobody has executed**:

<table>
  <thead>
    <tr><th>What the diagram says</th><th>What is often true underneath</th></tr>
  </thead>
  <tbody>
    <tr><td>"That call has a timeout"</td><td>The timeout is on <em>connect</em>, not on <em>read</em> — so a slow response waits forever</td></tr>
    <tr><td>"It retries on failure"</td><td>Three retries with no pause between them — the retry storm finishes the job the fault started</td></tr>
    <tr><td>"The cache serves stale data if the service is down"</td><td>The cache warms itself <em>from</em> that service, so it is empty exactly when it is needed</td></tr>
    <tr><td>"Search is optional on the product page"</td><td>The render awaits it, so an optional dependency is a hard one at runtime</td></tr>
    <tr><td>"The queue absorbs the spike"</td><td>The consumer is what fell over; the queue absorbs it into a four-hour backlog</td></tr>
  </tbody>
</table>

Every row is the same shape: a sentence that is true in the design and false in the deployment. No amount of traffic finds any of them, because at every level of load — 500 or 50,000 requests a second — the timeout is never reached, the fallback never runs, the retry never fires. **The code paths under test are only entered when something is broken.**

That is why a system can pass a 10× ramp on Monday and be taken down on Thursday by one non-critical service getting slower. The ramp exercised the paths that work. The outage lived in the paths that had never once executed.

---

## The discipline: what separates an experiment from an outage

The runner is where a chaos experiment earns the right to touch anything real.

```python
    def run(self, exp):
        if not exp.steady_state():             # is it healthy RIGHT NOW?
            return self.abort("not steady before we even started")

        exp.fault.apply(share=0.05)            # the blast radius
        try:
            for _ in range(exp.duration_s):
                time.sleep(1)
                if not exp.steady_state():     # the abort button
                    return self.stop(exp, "hypothesis disproved")
        finally:
            exp.fault.remove()                 # ALWAYS. even if this crashes.

        return "hypothesis held"
```

Four rules, all of them visible in ten lines:

1. **Check steady state before starting.** If the system is already degraded, the run teaches nothing and makes a bad afternoon worse. A degraded system is a reason to postpone, not to proceed carefully.
2. **Limit the blast radius explicitly**, as an argument you can read, not an implicit "well, staging is small".
3. **The abort is automatic and continuous.** A human watching a dashboard is not an abort button; the hypothesis is checked every second by the thing that caused the fault.
4. **`finally` removes the fault**, even if the runner itself crashes. A fault injector that can leak its own fault is strictly worse than never having run it — you have now added a failure mode instead of finding one.

And the practice has a prerequisite that is easy to skip past: **the result has to be observable.** If the system's health cannot be read in seconds, from the outside, in a business signal, then there is no steady state to measure and no abort condition to evaluate. You cannot run an experiment on a system you cannot see.

---

## A disproved hypothesis is the good outcome

<table>
  <thead>
    <tr><th>Result</th><th>What you walk away with</th></tr>
  </thead>
  <tbody>
    <tr><td>Hypothesis <strong>held</strong></td><td>One belief is now evidence, with a date on it. Valuable, and much less than it sounds — it is true for this fault, at this share, on this build.</td></tr>
    <tr><td>Hypothesis <strong>disproved</strong></td><td>A real outage was found on a Tuesday, at 5% blast radius, with the abort already armed and the whole team present. That is the point of the exercise.</td></tr>
  </tbody>
</table>

A team that only ever confirms its hypotheses is picking safe experiments. The interesting ones are the beliefs nobody wants to test.

---

## What each one honestly costs

<table>
  <thead>
    <tr><th></th><th>Load testing</th><th>Chaos engineering</th></tr>
  </thead>
  <tbody>
    <tr><td>Risk to production</td><td>Low — run it off to the side</td><td>Real, by construction</td></tr>
    <tr><td>Hard part</td><td>Making the traffic <em>realistic</em> — cache hit rates, payload sizes, the ratio of reads to writes</td><td>Organisational permission, and the observability to see the result</td></tr>
    <tr><td>Fails by</td><td>Passing while being wrong — a test that hits one warm endpoint proves nothing</td><td>Being skipped, or run as theatre with no hypothesis</td></tr>
    <tr><td>Prerequisite</td><td>An environment shaped like production</td><td>Health readable as a business signal in seconds</td></tr>
    <tr><td>Cadence</td><td>Before launches, and on capacity changes</td><td>Scheduled, small, boring, repeated</td></tr>
  </tbody>
</table>

Load testing is cheap and safe, and its cost is entirely in *fidelity* — a synthetic run with a 99% cache hit rate against a production reality of 60% measures a system that does not exist. Chaos is technically simpler and expensive in trust: the fault is real, in a real environment, and the first one costs a conversation, not a library.

---

## The same two questions on an AI serving stack

None of this is a backend-only concern; an LLM serving path is just a distributed system whose slowest dependency happens to be a GPU.

**The load test** for an inference service is still a ramp, but the numbers change. Requests per second is nearly meaningless when one request is 40 output tokens and the next is 4,000, so the ramp is driven in **concurrent streams with a realistic prompt-length mix**, and what gets recorded is:

- **TTFT** (time to first token) at p99 — the number the user actually feels, and the one that degrades first as the batch queue grows.
- **TPOT** (time per output token) at p99 — throughput once streaming has started.
- **Saturation of the real binding constraint**, which is usually not CPU: KV-cache memory, the batch queue depth, or the number of admitted concurrent sequences.

The knee looks exactly like the one in the table above. Continuous batching keeps TTFT flat while there is KV-cache headroom, and then requests start queueing for admission and TTFT bends sharply while TPOT stays almost unchanged — the same "median looks fine" trap, one layer up.

**The chaos experiment** on the same stack holds traffic at a normal Tuesday and audits the sentences in the RAG or agent diagram, which are exactly as unexecuted as any other:

- *"If the model provider gets slow, we fall back to the smaller model."* Injected as 2 seconds of extra delay on 5% of completion calls. Frequently disproved by a timeout that is longer than the user's patience, or a fallback that is never reached because the retry consumed the budget first.
- *"If the vector store is unavailable, the answer degrades to the model's own knowledge."* Injected as errors on 5% of retrieval calls. Frequently disproved by an exception that takes down the whole request instead.
- *"A rate-limit response is retried with backoff."* Injected as 429s on a small share. Frequently disproved by three immediate retries that turn one rate limit into a self-inflicted outage.
- *"A half-finished stream is handled."* Injected by cutting the connection mid-response — the case nobody wrote a branch for, because down is remembered and *partial* is not.

The pattern transfers whole: raising the load finds the capacity ceiling of your GPU fleet; breaking one thing finds out whether the fallback chain in the diagram exists anywhere other than the diagram.

---

## The verdict

<table>
  <thead>
    <tr><th></th><th>Load testing</th><th>Chaos engineering</th></tr>
  </thead>
  <tbody>
    <tr><td>The system is</td><td>Completely healthy</td><td>Deliberately broken, in one place</td></tr>
    <tr><td>The traffic is</td><td>Rising, up a ramp</td><td>Normal — a boring Tuesday</td></tr>
    <tr><td>The question</td><td>How much can this take?</td><td>What happens when a part of it stops working?</td></tr>
    <tr><td>The output</td><td>A number: the capacity ceiling</td><td>A verdict on a belief</td></tr>
    <tr><td>It validates</td><td>Your capacity plan</td><td>Your architecture diagram</td></tr>
    <tr><td>Blind to</td><td>Every failure path, because nothing fails</td><td>The volume you have never carried</td></tr>
  </tbody>
</table>

**One of them measures the ceiling. The other measures the floor.** Most systems have measured neither — only an average Tuesday.

A practical order of operations, if both are new:

1. **Make health readable first.** One business-level signal, visible in seconds. Without it, a load test has no verdict and a chaos experiment has no abort button.
2. **Run the ramp.** It is the cheap, safe one, and its output — *which resource saturates first* — is the map you need before touching anything on purpose.
3. **Write one hypothesis down**, as a sentence with a *because*. Choose the belief you would least like to be wrong about.
4. **Inject small, abort automatically, remove in `finally`.** Five percent, one fault, one variable.
5. **Expect to be wrong**, and treat that as the return on the exercise rather than a failure of the exercise.

Then read your own architecture diagram again. Every arrow on it is a claim; the only question is whether it has ever been executed, and by whom — you, on a Tuesday, or a customer, at 03:14.

---

**Want the long version?** The [full 11-minute episode](https://youtu.be/8XbSdOmp1Vo) walks all three files line by line on screen — the ramp and its knee, the hypothesis and its blast radius, and the runner's abort button — including the timeout-on-connect reveal. The [2-minute version is here](https://youtube.com/shorts/5q1_MB8sYUE) if you only want the trap.
