Failing Fast with Bail in Large Test Suites

When a pull request has already broken, the remaining nine minutes of a twelve-minute suite deliver no new information — the merge is blocked the instant the first test fails, so running the rest just burns compute and delays the red signal. Bail stops the runner at the first failure so feedback and billing both stop early. But bail is a double-edged setting: on a deterministic merge gate it is pure savings, while on a flaky or exploratory run it hides exactly the failure counts you need. This guide is for CI engineers running Vitest who want to apply bail where it pays and withhold it where it blinds you. We cover the two distinct fail-fast mechanisms — Vitest’s bail and the CI matrix’s fail-fast — a reproducible config, the profile split between pull-request and nightly runs, and the failure modes. Bail is the compute-saving lever in CI test orchestration.

Root Cause Analysis

The waste bail addresses comes from a mismatch between what a gating build decides and what it does. A merge gate is a boolean: it needs to know whether the commit passes, and the answer becomes false the moment any single test fails. Yet a default test run keeps executing every remaining test after that first failure, computing detail that cannot change the already-determined verdict. On a large suite that tail is most of the run, so the gate spends the bulk of its wall-clock time and billed minutes producing information no one acts on.

There are actually two independent fail-fast mechanisms, and conflating them is the source of most confusion. Vitest’s bail: N operates inside a single runner — it aborts that process after N failing tests. The CI matrix’s fail-fast: true operates across jobs — it cancels sibling shards when one shard’s job fails. They compose but mean different things: runner-level bail shortens one machine’s run, while matrix-level fail-fast shortens the fan-out. The reason bail is not simply always-on is that stopping early discards information. A run that bails after the first failure reports that a test failed but not how many or which, and for a suite where failures group around a shared cause, or one still fighting nondeterminism, that count is the diagnostic. So bail is correct precisely when the run is a trusted boolean gate and wrong when the run is an investigation — which is why it belongs in a per-profile configuration, not a global default. Suites still fighting nondeterminism should reach for quarantining flaky tests in CI before turning bail on.

Two fail-fast levels: runner bail versus matrix fail-fast Vitest bail aborts a single runner after N failures, while matrix fail-fast cancels sibling shard jobs when one shard fails. Runner-level — Vitest bail: 1 pass FAIL skipped skipped abort after first fail on one machine Matrix-level — fail-fast: true shard 1 FAIL triggers cancel shard 2 cancelled shard 3 cancelled cancels sibling jobs across the fan-out
Bail aborts one runner; matrix fail-fast cancels sibling shards.

Reproducible Setup

Vitest exposes bail as a config field and a CLI flag. Drive it from an environment variable so the same config behaves as a gate in CI and a full run locally.

npm install -D vitest
npx vitest run --bail=1        # stop at the first failing test
// vitest.config.ts — bail only under CI, never during local iteration
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // 0 = run everything; 1 = stop at first failure
    bail: process.env.CI ? Number(process.env.BAIL ?? 1) : 0,
  },
});

Local development sets no CI, so bail stays 0 and you always see every failure while iterating. CI sets CI=true, so the gate stops early by default, and a nightly job can export BAIL=0 to force a full run.

Implementation

Step 1 — Turn on runner bail for the gating pull-request run. The pull-request job is a boolean gate, so a single failure is enough to block the merge; bail after one failure and reclaim the tail of the run.

# .github/workflows/pr.yml
name: pr
on: pull_request
jobs:
  gate:
    runs-on: ubuntu-latest
    env: { BAIL: '1' }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run          # bail=1 via config, stops at first fail

Step 2 — Keep the nightly and main runs bail-free. The completeness build must report every failure so you can see the full breakage shape and trends, so it disables bail explicitly.

# .github/workflows/nightly.yml
name: nightly
on:
  schedule: [{ cron: '0 3 * * *' }]
jobs:
  full:
    runs-on: ubuntu-latest
    env: { BAIL: '0' }               # run everything, report all failures
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: '.nvmrc', cache: 'npm' }
      - run: npm ci
      - run: npx vitest run

Step 3 — Choose the matrix fail-fast setting deliberately, and independently. When you also shard, the matrix fail-fast decides whether one shard’s failure cancels the others. Cancelling saves the most compute, but it also throws away the sibling shards’ reports — so keep it false whenever you collect per-shard coverage or want the full failure picture, and only set it true on a pure pass/fail gate that produces no artifacts you need. This is the setting called out in sharding Vitest across GitHub Actions runners.

  gate:
    strategy:
      fail-fast: true          # OK only when no per-shard artifact is needed
      matrix: { shard: [1, 2, 3, 4] }
    steps:
      - run: npx vitest run --shard=${{ matrix.shard }}/4   # bail=1 within each shard

Step 4 — Raise the bail count for suites whose failures arrive together. If failures usually share a root cause and surface in a group, bail: 1 hides the shape of the breakage. Set bail to a small number like 5 so the run stops after enough failures to reveal that grouping but still well before the end.

Step 5 — Never bail a run you are using to measure flakiness. A flake-hunting or quarantine-lane run needs the full pass/fail/flaky count to compute a rate, and bail truncates that count to one. Force BAIL=0 on any job whose output feeds a flakiness dashboard.

Compute reclaimed when bail stops the gate early A full run executes all twelve minutes after an early failure, while a bailing run stops shortly after the failure and reclaims the remaining minutes. No bail — runs to the end tests pass FAIL keeps running — no new verdict 12:00 Bail 1 — stops at the failure tests pass FAIL reclaimed compute — stopped 4:10
On a boolean gate, everything after the first failure is reclaimable.

Verification

Bail is verified by confirming it changes timing without changing the verdict. A commit that fails must still fail with bail on, and a commit that passes must run the entire suite even with bail set, because bail only triggers on failure. Assert both.

# a passing commit must run every test even with bail=1
BAIL=1 npx vitest run --reporter=json --outputFile=pass.json
# a failing commit must stop early with a non-zero exit
BAIL=1 npx vitest run --reporter=json --outputFile=fail.json || echo "exited non-zero as expected"
// scripts/assert-bail.ts — verdict preserved, tail reclaimed on failure
import { readFileSync } from 'node:fs';

const pass = JSON.parse(readFileSync('pass.json', 'utf8'));
const fail = JSON.parse(readFileSync('fail.json', 'utf8'));

// a green run under bail must still execute the whole suite
if (pass.numFailedTests !== 0) throw new Error('Passing commit reported failures.');
// a red run under bail must stop before running everything
if (fail.numTotalTests >= pass.numTotalTests) {
  throw new Error('Bail did not stop early on failure.');
}
console.log('Bail verified: green runs full, red runs stop early.');

The two assertions together prove the contract. A green build under bail must execute the full suite, because there was no failure to trigger the stop — if it did not, bail is masking a broken run count. A red build under bail must execute strictly fewer tests than the green build, proving the tail was actually reclaimed. If both hold, bail is saving compute without altering which commits pass.

Bail's contract: green runs full, red runs stop early A passing commit under bail executes every test because no failure triggers the stop, while a failing commit executes strictly fewer tests, proving the tail was reclaimed. Green commit + bail runs the FULL suite no failure to trigger stop bail never shortens green Red commit + bail stops — fewer tests run tail reclaimed on failure verdict unchanged
Bail alters timing on the red path only; the green path always runs in full.

Troubleshooting

Bail hides how bad the breakage is. A run bails after one failure, so a change that broke fifty tests looks identical to one that broke a single test, and you fix one symptom only to hit the next. On the merge gate this is acceptable because any failure blocks the merge, but for triage, re-run with BAIL=0 — or route the investigation to the nightly full run — to see the whole failure set before you start fixing.

Matrix fail-fast cancels shards you needed reports from. With fail-fast: true and sharding, one shard’s failure cancels the siblings, so their coverage and failure reports never upload and a merge job downstream errors on missing artifacts. Set the matrix fail-fast: false whenever a later job consumes per-shard output; keep runner-level bail for the compute saving instead, since it does not cancel siblings.

Bail turns a flaky suite into noise. On a suite still fighting nondeterminism, bail: 1 stops at whichever flake fires first, so the reported failure changes run to run and the signal is useless for measuring a flake rate. Disable bail on any job that feeds flakiness metrics, and contain the underlying instability with quarantining flaky tests in CI before treating the gate as trustworthy enough to bail.

FAQ

Should bail be on for every CI run?

No — enable it only on runs that function as a boolean merge gate, where any single failure blocks the merge and the remaining tests cannot change that outcome. Keep it off for nightly and main-branch runs, which exist to report the complete failure set and trends, and off for any run whose output feeds a coverage merge or a flakiness dashboard. The clean rule is bail on the fast pull-request gate, bail off on the completeness build.

What is the difference between Vitest bail and matrix fail-fast?

They operate at different scopes. Vitest’s bail: N aborts a single runner process after N failing tests, shortening one machine’s run. The CI matrix’s fail-fast: true cancels the sibling jobs in a matrix when any one job fails, shortening the fan-out. They compose — you can bail within each shard and also cancel shards — but you choose them independently, and cancelling siblings is dangerous when you need their reports, so keep matrix fail-fast off whenever a downstream job consumes per-shard artifacts.

Does bail make my suite pass faster too?

No, and that is the key property. Bail only triggers on a failure, so a fully passing commit runs every test regardless of the bail setting and takes the same time it always did. The saving is entirely on the red path — a broken build stops early instead of running its full length. That is exactly why bail is safe on a gate: it never shortens a green run, so it cannot hide a test that would have failed later in a passing build.

What bail count should I choose?

Use 1 for a suite whose failures are usually independent, so the gate stops at the very first sign of breakage. Raise it to a small number like 5 when failures tend to group around a shared root cause and seeing several at once speeds diagnosis. Avoid large bail counts on a gate — past a handful you are nearly running the whole suite anyway, losing most of the compute saving while still risking a truncated picture, so at that point just set bail to 0 and run everything.