Skip to content
Latchkey

How to Reduce CI Flakiness Without Just Adding Retries

A blanket retry turns a visible problem into an invisible one. Measure the flake rate first, classify what is actually failing, and retry only the class that is genuinely transient.

Flakiness is expensive in two ways that compound: you pay for the failed run and the re-run, and every red build that turns out to be noise makes the next real failure slightly less believable. The second cost is the one that eventually breaks a team.

The instinct is to add a retry. That is correct for exactly one of the four causes below and actively harmful for the others, because it converts a defect you could have found into background noise you have learned to ignore.

Measure the rate before you fix anything

You cannot tell whether flakiness is improving without a baseline, and the number is usually worse than people estimate.

Terminal
# what share of recent runs are retries of a failed run?
gh run list --limit 200 --json databaseId --jq '.[].databaseId' |
while read -r id; do gh api "/repos/{owner}/{repo}/actions/runs/$id" --jq '.run_attempt'; done |
awk '{t++; if ($1>1) r++} END {printf "re-run rate: %.1f%% (%d of %d)\n", 100*r/t, r, t}'

# which jobs fail most often?
gh run list --limit 100 --json databaseId --jq '.[].databaseId' |
while read -r id; do
  gh api "/repos/{owner}/{repo}/actions/runs/$id/jobs" \
    --jq '.jobs[] | select(.conclusion=="failure") | .name'
done | sort | uniq -c | sort -rn | head

The four causes, and what each one needs

CauseHow it presentsCorrect response
Genuinely transient infrastructureRegistry 5xx, DNS blip, spot reclaimRetry. This is the one retries are for
Shared state between testsPasses alone, fails in the suiteIsolate the state. A retry hides it
Race conditions and timingFails ~1 run in 20, no patternFix the synchronisation. A retry hides it
Resource exhaustionExit 137, disk full, timeouts under loadRaise limits or reduce parallelism

Prove which one you have

Terminal
# shared state: does it pass alone?
<runner> path/to/one.test

# order dependence: shuffle the suite
<runner> --shuffle --seed 12345   # then replay with the same seed

# race: run it repeatedly and count
for i in $(seq 1 30); do <runner> path/to/one.test >/dev/null 2>&1 || echo "fail $i"; done

# resource: watch while it runs
/usr/bin/time -v <runner> 2>&1 | grep -E "Maximum resident|Exit status"

Quarantine, do not delete

A test that fails intermittently and cannot be fixed today should be quarantined into a non-blocking job rather than deleted or retried in place. Deleting loses the coverage; retrying in place hides the trend.

  • Move it to a job that runs but does not gate merges.
  • Record why it was quarantined and by when it will be fixed. Quarantine without an expiry becomes deletion with extra steps.
  • Track the quarantine list size as a number the team sees. A growing list is a signal; an invisible one is a graveyard.

Retry correctly when you do retry

.github/workflows/ci.yml
# retry the transient class only, at the step level, with a bound
- uses: nick-fields/retry@v3
  with:
    max_attempts: 3
    timeout_minutes: 10
    retry_on: error          # not on test assertion failures
    command: npm run test:integration

# and always record that a retry happened, or your real
# failure rate becomes invisible

Measure before you optimise

Pipeline optimisation usually targets the step people assume is slow. Get the real per-step timings first, because the answer is frequently dependency install or a cold cache rather than the build itself.

Terminal
# per-job timings for the last 20 runs
gh run list --limit 20 --json databaseId,conclusion,createdAt,updatedAt \
  --jq '.[] | "\(.conclusion)\t\(.createdAt)\t\(.updatedAt)"'

# per-step timing inside one run
gh run view <run-id> --log | grep -E "^\S+\s+.*Run |##\[group\]" | head -40

Frequently asked questions

What is an acceptable CI flake rate?
Below one percent of runs is generally tolerable; above five percent people stop trusting red builds, which is the expensive failure mode. Measure your re-run rate to get the number rather than estimating it.
Should I just retry flaky tests automatically?
Only for genuinely transient infrastructure failures such as registry timeouts or a reclaimed instance. Retrying shared-state bugs, races, or resource exhaustion hides a reproducible defect and makes it harder to fix later.
How do I know if a test is flaky or genuinely broken?
Run it in isolation, then run the suite with a shuffled order, then run it repeatedly. Passing alone but failing in the suite is shared state; failing roughly one run in twenty with no pattern is a race; failing consistently is not flakiness at all.
Why do tests fail in CI but pass locally?
Runners have fewer cores, no TTY, a different locale and timezone, less memory, and a cold cache. Timing-sensitive tests fail under contention, and snapshot tests containing formatted dates fail on a timezone difference. Pin TZ and LANG and reproduce with reduced parallelism.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card