Guides / Slow failures: expensive steps run before cheap ones

Too much work per run

Slow failures: expensive steps run before cheap ones

By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026

A developer pushes a commit with a missing semicolon. GitHub Actions spins up a full build job, burning 12 minutes on TypeScript compilation, dependency installation, and Docker layer construction. The build finishes. The integration tests start. Then a lint step at the end catches the syntax error. You just paid for 18 minutes of compute to discover a problem that eslint would have found in 10 seconds.

This is the “slow failure” pattern: CI discovers failures late because expensive steps run before cheap ones. The fix is job ordering: put fast, cheap checks at the front and gate expensive steps behind them with the needs keyword.

Symptoms

How to tell if late failures are wasting your CI budget

Open your repository's Actions tab and look at recent failed runs. If the pattern below looks familiar, you have a job ordering problem:

  • Failures after long build phases. Failed runs regularly consume 10–20+ minutes before reporting the error. The failure cause is something a lint or type-check step would have caught in seconds, such as a syntax error, a type mismatch, or an import typo.

  • High billable minutes on failed workflows. Compare the minutes consumed by failed runs versus passing runs. If failed runs consume nearly the same minutes as passing ones, your pipeline is doing most of its work before it can fail. A well-ordered pipeline should fail fast, meaning failed runs should be significantly shorter than passing ones.

  • All jobs run in parallel with no dependencies. Your workflow file has no needs keys. Every job starts immediately, meaning a lint failure discovered in 15 seconds doesn't prevent a 20-minute E2E suite from launching. You pay for both.

  • Lint and type-check are steps inside the build job. Instead of running as separate, fast, early jobs, lint and type-check are embedded as later steps in a monolithic job, running only after checkout, dependency install, and build. They can't short-circuit the expensive work because they run after it.

Metrics

How much do slow failures actually cost

The waste depends on two factors: how often builds fail from errors that cheap checks would catch, and how much compute runs before the failure surfaces. Here's a realistic scenario for a team with a 20% failure rate on PRs, where most failures are lint or type errors:

Before (flat pipeline)

Runs/day 40
Failed runs/day 8
Minutes/failed run 18
Wasted minutes/day 144
Monthly waste $57/mo

8 fails × 18 min × 22 days × $0.006/min

After (fail-fast ordering)

Runs/day 40
Failed runs/day 8
Minutes/failed run 2
Wasted minutes/day 16
Monthly waste $6/mo

Save $51/mo · $612/year · per workflow

That's one workflow on Linux at $0.006/min. On macOS runners at $0.062/min, the same scenario goes from $593/mo wasted to $66/mo, saving $527/mo. The savings also compound with the developer time saved: instead of waiting 18 minutes for a failure, the developer gets feedback in 2 minutes and iterates faster.


Fix 1

Gate expensive jobs behind cheap checks

GitHub Actions runs all jobs in parallel by default. If your workflow has a lint job, a build job, and an e2e job with no dependencies between them, all three start simultaneously. A lint failure at the 15-second mark doesn't stop the build or E2E jobs, which keep running and billing until they finish.

The needs keyword creates job dependencies. When a job specifies needs: [lint, typecheck], it won't start until those jobs succeed. If any dependency fails, the downstream job is skipped entirely, meaning it never starts, never provisions a runner, and consumes zero minutes.

all jobs run in parallel
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint

  build:
    # no needs - starts immediately
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

  e2e:
    # no needs - starts immediately
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:e2e
gated with needs
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint

  build:
    needs: [lint]
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

  e2e:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:e2e

In the left example, a lint failure at 15 seconds still costs you 12 minutes of build time and 20 minutes of E2E time. In the right example, a lint failure skips both downstream jobs, bringing the total cost to 1 billed minute (GitHub rounds up to the nearest minute).

Fix 2

Run cheap checks in parallel, then gate

You don't need to run all cheap checks sequentially. Lint, type-checking, and unit tests are independent of each other. Run them in parallel in the first tier, then gate the expensive jobs behind all of them. This gives you the fastest possible feedback without sacrificing the fail-fast benefit.

The needs keyword accepts an array. When a job specifies needs: [lint, typecheck, unit-test], it waits for all three to succeed. If any one fails, downstream jobs are skipped.

.github/workflows/ci.yml
name: CI

on:
  pull_request:

jobs:
  # Tier 1: cheap, fast checks (run in parallel)
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx tsc --noEmit

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm test

  # Tier 2: build (only if all cheap checks pass)
  build:
    needs: [lint, typecheck, unit-test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build

  # Tier 3: integration/E2E (only if build passes)
  e2e:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - run: npm run test:e2e

This creates a three-tier pipeline: lint, type-check, and unit tests run simultaneously. If all pass, the build starts. If the build passes, E2E tests run. A lint error at the 15-second mark skips both the build and E2E jobs. The total wall-clock time for a passing run isn't much longer than before because the cheap checks run in parallel while you'd be waiting for the build anyway.

One caveat: each job provisions its own runner and repeats setup steps (checkout, install). If your npm ci takes 90 seconds, three parallel jobs means 3 × 90 seconds of setup. For most teams this is worth it because the fail-fast savings exceed the setup overhead. But if your dependency install is very slow, consider combining lint and type-check into a single job with sequential steps to reduce runner overhead. See our too many small jobs guide for that tradeoff.

Fix 3

Extract cheap checks from monolith jobs

A common pattern is a single large job that runs checkout, install, build, lint, test, and E2E in sequence. Lint is step 4 of 6. Even when lint fails, the preceding build step already consumed 8–12 minutes. The fix is to extract fast checks into their own jobs and run them before the heavy work.

monolith job, lint runs late
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build     # 8 min
      - run: npm run lint      # fails here
      - run: npm test
      - run: npm run test:e2e
split into jobs, lint gates the rest
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run lint     # 15 sec

  ci:
    needs: [lint]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - run: npm test
      - run: npm run test:e2e

This is the minimum-effort version of the fix. You're not restructuring the entire pipeline, just pulling the fastest check out and making it a gate. If lint fails, the ci job is skipped entirely. The 8-minute build never starts.

For more aggressive ordering, also extract tsc --noEmit (type-check) into its own job. Type errors are the second-most common fast-catchable failure. Between lint and type-check, you'll gate out the majority of trivial failures before any heavy compute begins.

Fix 4

Reorder steps within a single job

If splitting into multiple jobs isn't practical, perhaps because your setup step is too slow to duplicate or your team prefers a single job, you can still get most of the benefit by reordering steps within the job. Move the fastest checks to run immediately after setup, before any build or test step.

.github/workflows/ci.yml
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci

      # Fast checks first - fail in seconds, not minutes
      - run: npm run lint             # ~15 sec
      - run: npx tsc --noEmit         # ~30 sec
      - run: npm test                 # ~2 min

      # Expensive steps last - only reached if everything above passes
      - run: npm run build            # ~8 min
      - run: npm run test:e2e         # ~12 min

Steps within a job run sequentially and stop on the first failure (unless continue-on-error: true is set, which it shouldn't be for checks). Moving lint before build means a lint failure exits the job after 2 minutes of setup + 15 seconds of lint, instead of after 2 minutes of setup + 8 minutes of build + 15 seconds of lint.

One caveat: step reordering only helps with the single-job model. It won't prevent parallel jobs from running. If your workflow already has separate jobs (build, test, deploy), you need the needs keyword from Fix 1 to get the cross-job benefit.


Reference

Job ordering from cheapest to most expensive

Order your pipeline by cost-to-failure. The cheapest checks should run first and gate everything downstream. Here are typical durations for common CI steps:

Step Typical duration Tier
Lint (ESLint, Rubocop, etc.) 5–30 sec 1
Type check (tsc, mypy, etc.) 10–60 sec 1
Unit tests 1–5 min 1
Build / compile 3–15 min 2
Integration tests 5–20 min 3
E2E tests (Cypress, Playwright) 10–30 min 3

Tier 1 checks run in parallel and gate Tier 2. Tier 2 gates Tier 3. The needs keyword is free, available on all GitHub plans including Free, and adds no overhead. There's no reason not to use it.

Reference

Complete fail-fast workflow

Here's all the fixes combined into a single workflow. Cheap checks run in parallel, expensive steps are gated, and failures exit fast.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  # Tier 1 - fast checks (parallel, ~1 min each)
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

  # Tier 2 - build (only if all Tier 1 jobs pass)
  build:
    needs: [lint, typecheck, unit-test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build

  # Tier 3 - E2E (only if build passes)
  e2e:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Related guides

Guides / Slow failures: expensive steps run before cheap ones

See which jobs waste minutes on failures

CostOps tracks minutes consumed on failed vs. passing runs and shows which jobs run before they need to. See the data before you restructure your pipeline.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.