Our CI Failed 40% of the Time and the Code Was Fine: A Flaky Test
"Just re-run it." Four words that quietly destroyed the trust of an entire engineering team. If you've ever typed those words into a Slack thread, this story is for you. A Red Build Nobody Believed It was 4
"Just re-run it."
Four words that quietly destroyed the trust of an entire engineering team.
If you've ever typed those words into a Slack thread, this story is for you.
A Red Build Nobody Believed
It was 4:47 PM on a Thursday. Priya, a backend engineer at a 12-person B2B SaaS startup, pushed a one-line fix for a typo in an email template. The pipeline went red.
She didn't read the logs. She didn't even open the failed job. She clicked Re-run all jobs, grabbed a coffee, and came back to a green build.
Nothing in the code had changed. The build had simply decided to pass the second time.
That moment, repeated dozens of times a week, is what we'll dissect in this case study. By the end of this post you will have:
- A clear definition of what a flaky test actually costs you (in hours, not vibes)
- A repeatable way to measure flakiness in your own pipeline
- A practical migration path from brittle scripted tests to a more resilient approach
- Code you can paste into your own repo today
The team's product was a subscription management dashboard. Their stack looked like most modern startups:
| Layer | Technology |
|---|---|
| Frontend | React + TypeScript |
| Backend | Node.js + PostgreSQL |
| CI | GitHub Actions |
| E2E tests | 140 Playwright scripts |
| Deploys | 8-10 per week |
On paper, this was a healthy setup. In practice, the end-to-end suite had become the thing everybody worked around instead of relying on.
The Problem: Quantifying the Pain
The team's engineering manager finally did what every engineering manager should do before complaining: she measured.
She pulled 30 days of CI history and wrote a small script to classify every failed run by whether a re-run of the same commit later passed.
#!/usr/bin/env bash
# flaky-report.sh
# Counts runs that failed, then passed on retry for the same commit SHA.
gh run list --limit 500 --json databaseId,headSha,conclusion,attempt \
| jq -r '
group_by(.headSha)[]
| select(any(.[]; .conclusion=="failure")
and any(.[]; .conclusion=="success"))
| .[0].headSha
' | sort -u | wc -l
The result was uncomfortable:
Total pipeline runs (30 days): 412
Runs that failed at least once: 171 (41.5%)
Failed-then-passed on same SHA: 139 (81% of failures)
Real failures (code was broken): 32
Eighty-one percent of the failures were not bugs. They were noise.
The real cost
She then did the math that got leadership's attention:
# cost_of_flakiness.py
flaky_failures_per_month = 139
avg_minutes_lost_per_failure = 22 # context switch + rerun + wait
engineers_blocked_ratio = 1.4 # others waiting on the same pipeline
hourly_cost = 55 # blended, USD
hours_lost = (flaky_failures_per_month
* avg_minutes_lost_per_failure
* engineers_blocked_ratio) / 60
print(f"Hours lost per month: {hours_lost:.1f}")
print(f"Approx. monthly cost: ${hours_lost * hourly_cost:,.0f}")
Output:
Hours lost per month: 71.3
Approx. monthly cost: $3,923
That is nearly half of one engineer's monthly capacity, burned on re-running tests that were not even telling the truth. And the invisible cost was worse: engineers had started ignoring red builds. Eventually, a real bug slipped through because everyone assumed "it's probably flaky again."
What They Tried First (And Why It Didn't Work)
Before looking for a new approach, the team tried the usual suspects.
Attempt 1: Add retries. They set retries: 2 in the Playwright config.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0,
timeout: 30_000,
});
Result: the pipeline went green more often, but run time increased by 35%, and the flakiness was now hidden rather than fixed.
Attempt 2: Add hard waits. Somebody sprinkled await page.waitForTimeout(3000) everywhere.
Result: slower tests, same flakiness, and a codebase full of magic numbers.
Attempt 3: A "flaky test quarantine." They tagged unstable tests and skipped them in CI.
Result: the quarantine list grew from 9 tests to 31 in six weeks. A quarantine that only grows is just a graveyard.
Diagnosing the Root Cause
The team finally sorted the 139 flaky failures by cause. This is the table that changed their thinking:
| Root cause | Share of flaky failures |
|---|---|
| Brittle selectors (CSS/XPath changed after UI tweaks) | 46% |
| Timing / race conditions on async UI | 29% |
| Shared test data collisions | 14% |
| Environment hiccups (network, container startup) | 11% |
Nearly half of the pain came from tests that were coupled to implementation details of the UI. A selector like this one was typical:
// Looks innocent. Breaks the moment a designer touches the DOM.
await page.click('#root > div.layout > main > section:nth-child(2) button.btn-primary');
A developer changes a wrapper div, nothing visible changes for the user, and the test dies.
The Turning Point
The insight was simple but uncomfortable: the tests were asserting on how the page was built, not on what a user could do.
The team started evaluating tools that describe tests in terms of user intent and use AI to locate elements, instead of hard-coding locators. That is where they tried an AI-driven QA platform. If you want to try the same approach on your own flaky suite, you can start with BotGauge here and run your first AI-generated tests before committing to any large migration.
The workflow they adopted looked like this:
- Describe the scenario in plain English instead of writing selectors.
- Let the platform generate and execute the test across the UI, API, and database layers.
- Rely on self-healing so that cosmetic UI changes don't produce false failures.
- Wire it into CI so results gate the merge just like unit tests do.
A scenario that used to be 40 lines of Playwright became something like this:
Scenario: Customer upgrades from Basic to Pro plan
Given I am logged in as a Basic plan customer
When I open the Billing page
And I choose the Pro plan and confirm payment
Then I should see a confirmation message
And my plan should show as "Pro" in Account Settings
No nth-child. No waitForTimeout. The test reads like the requirement it protects.
The Migration Playbook
They did not rewrite everything at once. Here is the order that worked, and it's a good template for you:
Week 1 Measure flakiness (the scripts above). Rank tests by failure count.
Week 2 Migrate the 10 flakiest critical-path tests (login, checkout, upgrade).
Week 3 Run old and new suites in parallel. Compare signal, not just speed.
Week 4 Retire migrated Playwright tests. Keep the rest until their turn.
Week 5+ Continue by risk tier. Delete the quarantine list as you go.
Wire the new suite into the pipeline like any other quality gate:
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
e2e:
needs: unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Trigger AI end-to-end suite
run: ./scripts/run-e2e-suite.sh
env:
E2E_API_TOKEN: ${{ secrets.E2E_API_TOKEN }}
The Results (After 8 Weeks)
Illustrative numbers from the scenario, tracked using the same scripts from earlier:
| Metric | Before | After |
|---|---|---|
| Pipeline runs failing at least once | 41.5% | 9% |
| Failures that were real bugs | 19% | 88% |
| Hours lost per month to re-runs | ~71 | ~9 |
| Quarantined tests | 31 | 0 |
| Engineers saying "just re-run it" | Daily | Rarely |
The most important number is "Failures that were real bugs." When a build turns red now, people read the logs. That is what a healthy signal looks like.
Key Lessons You Can Steal
- Measure before you migrate. Without the 30-day report, this would have stayed an anecdote.
- Retries hide problems, they don't solve them. Use them as a temporary bandage and track them as debt.
- A growing quarantine is a smell. Set a hard cap and an expiry date for every quarantined test.
- Test intent, not implementation. If a designer's CSS tweak can break your test, your test is testing the wrong thing.
- Trust is the real product of a test suite. Speed and coverage matter, but only if people believe the result.
Your Turn
Run the flaky-report.sh script against your own repository this week. If more than 15% of your failures disappear on re-run, you don't have a testing problem, you have a trust problem.
Have a flaky-test horror story of your own? Drop it in the comments. I read every one.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.