Module 7: Analyzing Results And Ci

7. When to run the load test

Overview

You already know how to put the load test in a pipeline (lesson 6). The question the on: block of that load.yml left open remains: when does it trigger?. The natural answer —"on every PR, like the unit tests"— is a mistake, and understanding it separates whoever copied a YAML from whoever knows how to operate performance tests. A load test is slow, expensive, noisy, and needs a stable environment, qualities that make it a terrible candidate for running on every change. In this lesson you learn the correct cadence: nightly (scheduled, every night), pre-release (before a deploy or a tag), and on-demand (manually when needed), reserving the "on every change" for the fast and cheap tests. You see the GitHub Actions on: triggers that express each cadence (content) and the criterion for choosing between them.

Connection to the module: this lesson completes the automating half. Lesson 6 set up the pipeline (the how); this one decides its cadence (the when). It reuses lesson 6's load.yml, filling its on: block with judgment. With it, module 7 closes: you know how to produce a test (M2–M6), analyze it (lessons 2–5), and automate it with the correct frequency (lessons 6–7). What comes next is the capstone (M8), where all this comes together in a complete load test.

You don't put the car on the test bench every time you tighten a bolt

A car shop has two types of check, and it doesn't confuse them. When a mechanic tightens a bolt, they do a quick check: they turn the part by hand, verify it's firm. It takes seconds, they do it after each adjustment, and it's very cheap. But to know whether the car performs —power, consumption, behavior at high speed— they put it on a test bench (a dynamometer): expensive equipment that requires mounting the car, calibrating sensors, running a complete cycle, and that occupies a whole bay for a good while. No one puts the car on the dynamometer every time they tighten a bolt —it would be absurd, extremely expensive, and wouldn't let them work—. The test bench is used at the end of an important assembly or on a schedule, not after every small change.

Unit tests are the by-hand check: fast, cheap, on every change. The load test is the test bench: expensive, slow, carefully set up, run at chosen moments. Putting the load test on every PR is putting the car on the dynamometer for every bolt. The correct cadence —nightly, pre-release, on-demand— is using the test bench when it really adds value: periodically and before delivering.

Why the load test does NOT go on every PR

Four reasons, each sufficient on its own:

  • It's slow. A meaningful load test lasts minutes, not seconds: you have to ramp up, sustain the load for a while, ramp down (the module-4 profiles). As an executed reference from this guide: even a tiny run of 600 requests against a ~45 ms endpoint took 1.1 s of wall-clock time (the fast baseline, 0.11 s); a realistic multi-minute test at thousands of VUs is much more. Multiply that by every PR of an active team and the pipeline clogs.
  • It's expensive. Generating real load consumes machines (sometimes several, to produce enough traffic). Running it on every push is an infrastructure cost that isn't justified when most changes don't affect performance.
  • It's noisy. The p95 varies between runs from how the operating system distributes resources (you saw it in lesson 4: +0.8% between two identical runs). On a shared CI runner, that noise is worse. Running it on every PR would fill it with false reds from noise, and a gate that cries wolf ends up ignored.
  • It needs a stable environment. For the numbers to mean something, the target must be in an environment similar to production and isolated. An ephemeral, shared runner, different on each PR, gives measurements that can't be compared with each other. Without a stable environment, there's no reliable baseline —and without a baseline, regression detection (lesson 4) doesn't work—.

The consequence is clear: performance isn't protected by running the test more times, but by running it at the correct moments with a reliable environment.

The three correct cadences (with their triggers)

Each cadence is expressed with a GitHub Actions on: trigger. Labeled content (git/gh are not run here):

Nightly — scheduled, every night. The background safety net: it runs without anyone triggering it and catches regressions that snuck in during the day. It's where the baseline lives (last night's run, against which you compare today's).

# CONTENT (not run here): nightly trigger. See docs.github.com/actions.
on:
  schedule:
    - cron: "0 3 * * *"   # every day at 03:00 UTC (off-peak)

Pre-release — before deploying. The gate that protects the launch: it runs when a version is about to be published (a tag, a release), exactly when it matters most to know whether the performance holds up. Here the threshold as a gate (lesson 6) has its maximum value: it blocks a deploy that would degrade the performance.

# CONTENT (not run here): pre-release trigger (on publishing a tag/release).
on:
  release:
    types: [published]
  push:
    tags:
      - "v*"              # any version tag, e.g. v1.4.0

On-demand — manually. The lever for when an engineer knows their change touches performance (they touched a query, changed an algorithm) and wants to test before merging, without waiting for the nightly.

# CONTENT (not run here): manual trigger ("Run workflow" button in the UI).
on:
  workflow_dispatch:      # triggered manually from the Actions interface

In practice the three are combined in a single on: (like in lesson 6's load.yml): background nightly, pre-release as the launch gate, and on-demand for when someone needs it. What's not on that list is pull_request —the one that runs on every PR—, for the four reasons above.

Where it fits in the testing pyramid

This isn't an isolated rule; it's the testing pyramid applied to cadence. The pyramid says: many fast, cheap tests at the bottom, few slow, expensive ones at the top. The frequency with which you run each type follows that shape:

Test typeSpeedWhen it runsWhy
Unitmillisecondson every change (each push/PR)fast and cheap: immediate feedback
Integrationsecondson each PR or mergesomewhat slower, still viable per change
E2E (browser)seconds–minutesper important PR / pre-mergeslower; you select what to run
Load / performanceminutesnightly + pre-release + on-demandslow, expensive, noisy, needs a stable environment

The load test is at the tip of the pyramid, alongside E2E (as module 1 established): few, valuable, run at chosen moments. Running it with the frequency of a unit test is inverting the pyramid —putting the expensive and slow where the cheap and fast goes—, and an inverted pyramid is slow, fragile, and expensive. The correct cadence keeps the pyramid upright.

Common mistakes

Putting on: pull_request on the load workflow. What happens: the load test runs on every PR, the pipeline becomes very slow, and the false reds from noise make the team start re-launching or ignoring the gate. Why it happens: the unit-test pattern gets copied without thinking about the cost. How to detect it: PRs that take long because of the load step, or a performance gate people skip. How to fix it: remove pull_request; use schedule + release + workflow_dispatch. The "on every change" is for the fast and cheap.

Having no nightly (only pre-release). What happens: load is only run before a launch, so a regression that entered three weeks ago is discovered on release day —late, with many candidate changes, under pressure—. Why it happens: it's thought that the launch gate is enough. How to detect it: regressions that appear "out of nowhere" before a release and are hard to attribute. How to fix it: add the nightly as a background net; it catches regressions near the change that caused them, when there's a single candidate culprit (the difference between yesterday's and today's runs).

Running in an unstable environment and trusting the numbers. What happens: the test runs on a shared, ephemeral runner, different each time, and the results can't be compared between runs. Why it happens: "runs in CI" gets prioritized over "means something." How to detect it: a p95 that jumps erratically with no relation to the code changes. How to fix it: run the load against a stable and isolated environment (similar to production, dedicated), so the baseline is reliable and the comparisons (lesson 4) make sense.

Exercises

Exercise 1 — Choose the cadence. For each situation, say what trigger you'd use (schedule, release/push tags, workflow_dispatch, or pull_request) and why. (a) A safety net that catches regressions near when they come in. (b) A gate that prevents publishing a version with degraded performance. (c) An engineer who touched a query and wants to test before merging. (d) Verifying the logic of a pure function after each change.

See solution
  • (a) schedule (nightly). It runs every night with no intervention and catches regressions with a single day of candidate changes.
  • (b) release / push of tags (pre-release). The gate protects the launch right before deploying.
  • (c) workflow_dispatch (on-demand). The engineer triggers it manually when they know their change touches performance.
  • (d) pull_request —but for a unit test, not a load one—. It's fast and cheap, it goes on every change. The load test doesn't.

Exercise 2 — Why not on every PR? Name the four reasons a load test doesn't go on every PR, and for each explain in one sentence why a unit test can go on every PR.

See solution
  • Slow (minutes): the unit takes milliseconds, so it doesn't clog the pipeline.
  • Expensive (consumes machines to generate load): the unit runs on the same runner with no extra infrastructure.
  • Noisy (the p95 varies between runs): the unit is deterministic —same input, same output—, with no false reds.
  • Needs a stable environment (for the numbers to compare): the unit doesn't depend on the environment; it tests isolated logic, the same on any runner.

The asymmetry is the pyramid: the fast/cheap/deterministic goes at the bottom and on every change; the slow/expensive/noisy goes at the top and at chosen moments.

Exercise 3 — Design the on:. Write (as content) the on: block of a load.yml that combines the three correct cadences: nightly at 02:00 UTC, on publishing a release, and manually. Why do you not include pull_request?

See solution
# CONTENT: on: with the three correct cadences.
on:
  schedule:
    - cron: "0 2 * * *"     # nightly at 02:00 UTC
  release:
    types: [published]       # pre-release: on publishing a version
  workflow_dispatch:         # on-demand: manual

pull_request isn't included because the load test is slow, expensive, noisy, and needs a stable environment: running it on every PR would clog the pipeline, waste infrastructure, generate false reds from noise, and give non-comparable numbers. The "on every change" is reserved for the fast and cheap tests (unit); the load lives at the tip of the pyramid, at chosen moments.

Summary and next step

In this lesson you learned when to run the load test. The answer isn't "as often as possible" but "at the correct moments, with a reliable environment": nightly (background safety net, where the baseline lives), pre-release (the gate that protects the launch), and on-demand (the lever for when a change touches performance). And not on every PR, for four reasons —it's slow, expensive, noisy, and needs a stable environment—, which are the same ones that place it at the tip of the pyramid, alongside E2E, and not at the base with the unit tests. You saw the on: triggers that express each cadence (schedule, release, workflow_dispatch) and why pull_request isn't among them.

With this you close module 7: you know how to produce a load test (M2–M6), analyze it —read the summary, export, detect a regression, locate the bottleneck (lessons 2–5)— and automate it with the correct pipeline and the correct cadence (lessons 6–7). Before moving on you should be able to: name the three cadences and their trigger; give the four reasons the load doesn't go on every PR; and place it in the pyramid. What comes next, in lesson 8, is the mini-project that brings this module's analysis half together with your own hands: you run two loads, export them, detect the regression with an exit code, and write the CI load.yml —the whole module, executed from start to finish—.

Resources