Skip to main content
Back to Blog
Guide
2026-07-16

Fail-Fast vs continue-on-error in CI Test Jobs: When to Use Each

Use CI fail-fast continue-on-error jobs correctly to cut wasted test time, preserve diagnostics, and keep required merge checks trustworthy in every pipeline.

Fail-Fast vs continue-on-error in CI Test Jobs: When to Use Each

Two CI settings that both appear to "keep the pipeline moving" can produce opposite outcomes. Fail-fast stops related work after a decisive failure. continue-on-error allows work or a workflow to continue despite a failure. One saves compute by ending low-value execution. The other collects evidence from a failure that is not allowed to block delivery.

For QA teams, the distinction is a release-policy decision, not YAML decoration. Apply the wrong one and a pull request may look green despite a broken contract test, or a 30-shard browser run may stop after the first failure and hide 12 unrelated regressions. This guide turns both controls into explicit test policies using documented GitHub Actions behavior and portable decision rules.

Separate early termination from failure tolerance

Fail-fast answers: "After one member fails, should the remaining members still run?" In GitHub Actions, strategy.fail-fast controls jobs generated by a matrix. It defaults to true. When enabled, a non-tolerated matrix job failure cancels in-progress and queued jobs in that matrix.

continue-on-error answers: "Should this failure make the job or workflow fail?" At the step level, it allows a job to continue when that step fails. At the job level, it prevents that job's failure from failing the workflow run. It does not make the underlying assertion pass.

ControlPrimary decisionAppropriate exampleMain danger
Matrix fail-fastStop sibling matrix work after failureCompiler compatibility smoke tests where one failure is decisiveLosing diagnostic coverage from canceled combinations
Fail-fast disabledLet every matrix member finishBrowser or shard matrix used to map the regression surfaceHigher compute use after an obvious blocker
Step continue-on-errorContinue later steps in the same jobTemporary migration probe followed by evidence collectionHiding a required test behind a green conclusion
Job continue-on-errorKeep an allowed job failure from failing the workflowExperimental runtime axis outside the release contractStakeholders interpreting green workflow status as full support

These controls can coexist. For example, an experimental matrix member may tolerate failure while stable members remain blocking. The policy must be visible in job names, branch protection, and reporting.

Classify tests by what a failure means

Before editing CI, give each suite an enforcement class. Do not start by asking whether developers dislike waiting. Start by asking what shipping with a failure would mean.

Test classExampleFailure policyExecution policy
Release blockerAuthentication contract, migration check, checkout E2EMust fail required checkFail early only if remaining results add little value
Diagnostic suiteCross-browser compatibility matrixUsually blocking for supported targetsOften finish all targets to expose scope
Informational canaryUpcoming runtime or browser channelAllowed temporarily with owner and expiryFinish and publish result
Cleanup/reportingTrace upload, JUnit publication, environment teardownMust run after test failureUse an always-run condition, not failure tolerance on tests

This classification exposes a frequent misuse: adding continue-on-error so reports upload after a test command fails. Report publication should use a condition that runs after success or failure. The test step should retain its real result.

An AI coding agent changing pipeline YAML should be given the enforcement class and required-check names. "Make CI pass" is unsafe because the shortest edit may simply tolerate the failure. Ask the agent to preserve blocking semantics, show the resulting status graph, and explain every tolerated failure.

Use fail-fast for fast, decisive matrix feedback

Consider a small API smoke suite against supported Node.js versions. If every matrix member runs the same contract checks and the first stable target fails because a shared endpoint returns 500, canceling siblings may save time. GitHub Actions expresses that with the matrix strategy.

jobs:
  api-smoke:
    name: API smoke on Node ${{ matrix.node }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        node: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm run test:api-smoke

This is valuable when one failure is enough to reject the change and sibling output is mostly repetitive. It is less useful when matrix members exercise different behavior. Chromium, Firefox, and WebKit failures are not interchangeable evidence. Nor are database versions, regional endpoints, or independent test shards.

Use fail-fast: false when the matrix is your diagnostic map:

jobs:
  browser-tests:
    name: E2E ${{ matrix.browser }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        browser: [chromium, firefox, webkit]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps ${{ matrix.browser }}
      - run: npx playwright test --project=${{ matrix.browser }}

Now every browser job can finish even if one fails. The workflow still fails when a required browser job fails. You spend more compute, but receive the full compatibility picture in one run.

Reserve continue-on-error for explicitly non-blocking risk

A tolerated job should have three things: a reason, an owner, and an exit condition. "Flaky" is not enough. A concrete example is a canary runtime the team does not yet claim to support. It can reveal future incompatibility without blocking today's merge.

GitHub Actions allows continue-on-error to depend on a matrix value:

jobs:
  unit-tests:
    name: Unit tests, Node ${{ matrix.node }}
    runs-on: ubuntu-latest
    continue-on-error: ${{ matrix.experimental }}
    strategy:
      fail-fast: true
      matrix:
        include:
          - node: 22
            experimental: false
          - node: 24
            experimental: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

Here the supported target is blocking, while the experimental target is permitted to fail. GitHub's matrix documentation also describes the interaction: a tolerated experimental job does not trigger cancellation of the other matrix jobs under fail-fast.

Name the canary clearly in the UI and create a review date in the repository's normal tracking system. When the runtime becomes supported, change the value to blocking. If nobody reads the result, delete the job. A permanently ignored red test consumes compute without reducing risk.

Continue a job without erasing the original step outcome

Step-level tolerance is narrower. It can be useful during a migration when a legacy comparison is informative but not yet gating. GitHub exposes both outcome, the result before continue-on-error, and conclusion, the result after it. That lets later steps record the truth.

      - name: Compare legacy snapshots
        id: legacy
        continue-on-error: true
        run: npm run test:legacy-snapshots

      - name: Record legacy probe result
        if: ${{ always() }}
        run: |
          echo "Legacy outcome: ${{ steps.legacy.outcome }}"
          echo "Legacy conclusion: ${{ steps.legacy.conclusion }}"

Do not use this pattern for a required security, contract, or regression suite. A later echo does not restore merge protection. If the comparison must block, remove tolerance and keep evidence upload under an always-run condition.

A good migration probe also has a removal condition, such as "delete after the new serializer matches all approved fixtures." Add a test owner and report its failure rate. Otherwise the exception becomes invisible infrastructure.

Preserve traces, JUnit, and cleanup after failure

QA jobs often need more execution after the main command fails. Playwright traces, screenshots, server logs, and JUnit XML make the failure actionable. Keep the test blocking, then use if: ${{ always() }} on evidence and cleanup steps.

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload Playwright report
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          if-no-files-found: ignore

      - name: Stop test environment
        if: ${{ always() }}
        run: ./scripts/stop-test-environment.sh

The test step remains failed, the artifact step gets a chance to run, and cleanup does not depend on a green assertion. This is the cleanest answer when someone proposes continue-on-error only because teardown was being skipped.

Artifact upload itself can fail for operational reasons. Decide whether missing evidence should block the workflow. For a regulated audit trail it may need to. For a best-effort screenshot from an already failed test, the original failure is usually the primary signal. That is a separate policy from tolerating the tests.

Choose a policy for shards, retries, and flaky tests

Sharded E2E jobs are the hardest case. With fail-fast enabled, one shard can cancel other shards before they reveal independent failures. With it disabled, all shards complete and provide a reliable failure count, but a doomed run continues consuming browser minutes.

Use these questions:

QuestionFavor fail-fastFavor complete execution
Does one failure make the commit unmergeable?YesNot sufficient by itself
Do matrix members cover the same risk?YesNo, they cover distinct targets
Is a full failure map needed for one repair cycle?NoYes
Is the suite extremely costly after the first decisive result?YesOnly with a compute budget
Are artifacts from every shard needed to diagnose flakiness?NoYes

Retries do not convert flaky tests into non-blocking tests. A bounded framework-level retry can gather evidence and label intermittent behavior, but the policy should still say whether a test that passes only after retry blocks delivery. Keep retry counts in reports and track the first-attempt failure rate.

For persistent instability, publish structured results and assign ownership. GitLab CI JUnit reporting for flaky tests shows how test evidence can support that process in GitLab. The general lesson applies elsewhere: tolerance without visibility becomes silent quality debt.

Prevent duplicate work before tuning cancellation

Matrix fail-fast only responds after a matrix job fails. It does not address a newer commit making the entire older workflow irrelevant. For pull requests with frequent pushes, add workflow concurrency so obsolete runs do not compete with the newest candidate. The implementation details are covered in canceling stale E2E runs on a new commit.

Then choose within-run behavior separately:

# Policy review checklist for a CI change
# 1. Is this suite required for merge?
# 2. Which sibling jobs are canceled after failure?
# 3. Which failures remain visible but non-blocking?
# 4. Do reports and cleanup run after failure?
# 5. Who owns each temporary exception, and when is it removed?

This order prevents three policies from being conflated: obsolete-run cancellation, matrix early termination, and failure tolerance. They save time in different situations and change confidence in different ways.

Audit the status users actually see

After changing YAML, test the policy on a branch with controlled failures. Make one supported matrix member fail, then one experimental member fail. Confirm which siblings are canceled, which jobs show failure, whether required checks block merging, and whether artifacts appear.

Do not review only the workflow's final color. Inspect individual job and step results. A tolerated failure may leave the workflow successful, exactly as configured, while still representing a canary defect. Dashboards and notifications should surface that distinction.

Document the final policy beside the workflow: required targets, diagnostic targets, canaries, and always-run evidence steps. This gives both humans and coding agents a stable specification. A future refactor can then prove it preserves enforcement instead of merely producing syntactically valid YAML.

Frequently Asked Questions

Does fail-fast make a failing CI matrix pass?

No. Fail-fast controls whether sibling matrix jobs continue after a qualifying failure. It does not change that failed job into a success. The workflow still reflects the blocking failure. What changes is the amount of additional evidence collected from queued or running matrix members, so teams should enable it only when those canceled results are less valuable than the saved time.

Is continue-on-error a reasonable permanent fix for flaky tests?

Usually not. It removes merge pressure without removing the defect, and the tolerated result is easy to ignore. Use bounded retries to collect flake evidence, publish structured results, assign an owner, and repair or quarantine the test under a written policy. If temporary tolerance is necessary, give it a narrow scope, visible job name, measured failure rate, and expiry condition.

How can artifacts upload after a blocking test failure?

Leave the test command blocking and place if: ${{ always() }} on the upload step. The same pattern can run teardown or log collection after success, failure, or cancellation conditions that allow the step to start. This preserves the original test result while retaining diagnostics. There is no need to mark the assertion step continue-on-error merely to reach report publication.

Should browser test shards use fail-fast?

Choose based on the value of unfinished shard results. Enable it for a quick required signal when any failure rejects the commit and full coverage would add little diagnostic value. Disable it when shards hold distinct tests, teams need the complete regression map, or flake analysis depends on every report. Many teams use a fail-fast smoke stage followed by complete sharded execution for candidates that pass.