open atlas
↑ Back to track
CI/CD pipelines CICD · 06 · 02

Pipeline performance: caching, parallelism, fail-fast

Make one pipeline fast and cheap. Cache deps on a lockfile hash with restore-keys fallback, run the cheapest checks first behind needs, fan out only where it pays, quarantine flaky tests, and watch that parallelism doesn't just buy idle runner minutes.

CICD Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The pull request is one line: a renamed variable. CI runs for 35 minutes — install, build, unit tests, then the full Playwright e2e suite across three browsers — and then fails. The error is a typo: lenght instead of length, the kind of thing a typecheck catches in two seconds. But typecheck ran as a step inside the e2e job, after the browsers booted, so the cheapest, most-certain failure waited behind the slowest, flakiest work in the pipeline. Multiply that by forty pushes a day and the team is burning hours of human waiting and hundreds of runner-minutes to learn something a two-second job could have told them first.

Caching: the right key is a lockfile hash

When you’re looking at a slow pipeline and wondering where to start, the answer is almost always the same: cache what’s deterministic, and put the fast checks first. Here is how each piece works.

Every CI job starts on a clean runner, so without a cache it redoes every expensive deterministic step — chiefly the dependency install — from scratch on each run. A cache fixes that, but only if its key is right. The key is a string; a cache hits when that exact string already exists, otherwise it misses and a fresh cache is saved at the end of a successful job. So the key must change exactly when the cached contents should change, and never otherwise.

For dependencies the input that determines node_modules is the lockfile, so you hash it with hashFiles. Add restore-keys as an ordered list of prefixes: on a miss, GitHub walks them in order and restores the most recent cache whose key starts with the prefix — a partial hit, so npm ci reinstalls only the delta instead of all 1,200 packages over the network.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node22-
      ${{ runner.os }}-

There are three cache layers worth distinguishing, each keyed the same way: the dependency cache (~/.npm, ~/.cache/pip, Go module cache — often automatic via setup-node’s cache: npm), the build cache (incremental compiler output, Turborepo/Nx remote cache), and the Docker layer cache (type=gha with buildx). The anti-pattern that kills all three is the same: a volatile key. Bake a timestamp or ${{ github.run_id }} into the key and it is unique every run — so it never hits, the cache silently saves and is never read, and you pay full install time forever while believing you have a cache.

Fail-fast: cheapest, most-likely-to-fail first

needs turns a flat list of jobs into a DAG: a job with needs: [lint] will not start until lint succeeds. That single keyword lets you gate the slow, expensive work behind the cheap work — so a two-second lint failure short-circuits the run before a twenty-minute e2e suite ever boots a browser. The ordering rule is cheapest and most-likely-to-fail first: lint and typecheck run in seconds and catch the most common mistakes, so they go at the front of the DAG and everything slow hangs off them with needs.

CheckTypical timeOrderWasted on the typo PR
lint / typecheck~2–15 sfirst (gate)finds it in seconds
unit tests~1–3 minneeds: lintnever runs — skipped
e2e (3 browsers)~15–25 minneeds: [unit, build]never runs — skipped
flat (no needs)~25 minall at oncepays the full e2e, then fails on the typo
jobs:
  lint:                       # ~10 s — the cheap gate
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm run lint && npm run typecheck }]

  unit:
    needs: lint               # never starts if lint fails
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm test }]

  build:
    needs: lint               # parallel with unit — both only need lint
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npm run build }]

  e2e:
    needs: [unit, build]      # the slow suite is gated behind everything cheap
    strategy:
      fail-fast: true         # one shard fails → cancel the rest
      matrix:
        browser: [chromium, firefox, webkit]
    runs-on: ubuntu-latest
    steps: [{ uses: actions/checkout@v4 }, { run: npx playwright test --project=${{ matrix.browser }} }]
Why this works

fail-fast: true is the matrix-level twin of needs ordering: it is the default, and it cancels in-progress and queued shards the instant one shard fails. On a PR check that is what you want — you already know the build is red, so why pay for the other two browsers to finish. Flip it to false only for a nightly or release run where you want the complete failure report across every cell, not the fastest possible red.

Parallelism and a matrix: split work, but fan-out isn’t free

When jobs are independent — unit and build above both only need lint — they run in parallel automatically, cutting wall-clock time. A matrix is the compact way to fan one job over a list (browsers, Node versions, OSes), spawning one job per cell. The catch is cost. GitHub bills each job’s minutes independently and rounds each job up to the whole minute, so a 3-browser × 2-OS matrix is six separate jobs, each paying its own checkout, install, and cache-restore overhead. Parallelism trades billable minutes for latency: five 1-minute shards finish in one minute of wall-clock but bill five minutes. Fan out where the latency win is real; cap runaway fan-out with max-parallel.

Cost: minutes, runner size, and idle

Before reaching for a bigger runner or a wider matrix, ask yourself: will this job actually run faster, or will the extra cores just sit idle and bill me more?

Runner minutes are the bill. Linux is the cheap baseline; Windows costs roughly 2× per minute and macOS roughly 10×, so a careless macOS matrix is the fastest way to a surprise invoice. Two more levers cut both ways. A bigger runner (more vCPUs) finishes a CPU-bound job faster but costs proportionally more per minute — worth it only if your job actually parallelises across cores; a 16-core runner that runs a single-threaded build just bills 8× for nothing and sits idle. And cache restore time is not free: restoring a multi-hundred-MB cache over the network can cost more than the rebuild it saves, so cache the slow-to-produce things (deps, compiled output), not everything.

Quiz

A workflow's cache key is `build-${{ github.run_id }}`. Builds are still slow and the cache never seems to help. Why?

Pick the best fit

A 35-minute pipeline's wall-clock is dominated by a single-threaded `npm run build` plus a `npm ci` that reinstalls 1,200 packages every run. You want the biggest cut for the least extra billable cost. Which move first?

Order the steps

Order these CI jobs into a fail-fast DAG — cheapest, most-likely-to-fail first, slowest gated last:

  1. 1 lint + typecheck — seconds, catches the most common mistakes (the gate)
  2. 2 unit tests — a minute or two, needs: lint
  3. 3 build — parallel with unit, needs: lint
  4. 4 e2e matrix (3 browsers) — 15–25 min, needs: [unit, build], fail-fast: true
  5. 5 deploy — only on green, needs: e2e
lesson.inset.note

Flaky-test quarantine (recap from the testing unit): a test that passes and fails on identical code erodes trust and, worse, gets papered over by auto-retries that can mask a real regression. Don’t let a known-flaky test gate the pipeline. Tag it, move it into a separate non-blocking job (or mark it test.fixme/continue-on-error), and track it on a fix list — so the green check stays meaningful and a genuine failure is never re-run away.

Recall before you leave
  1. 01
    Why does a cache key like `build-${{ github.run_id }}` make the cache useless, and what is the correct key plus fallback?
  2. 02
    Explain how `needs` and fail-fast cut feedback time, and the cost trap of fanning everything into a matrix.
Recap

A fast, cheap pipeline comes from three moves layered together. First, cache the expensive deterministic steps on a key that is a lockfile hash — hashFiles('**/package-lock.json') — with ordered restore-keys prefixes so a changed lockfile still restores a partial cache and reinstalls only the delta; the fatal mistake is a volatile key (a timestamp or run_id) that is unique every run, so it never hits and you pay full install time while believing you are cached. The three cache layers — dependency, build, and Docker layer cache — all obey the same key rule. Second, order jobs cheapest and most-likely-to-fail first: needs builds a DAG so a two-second lint/typecheck failure short-circuits the run before a twenty-minute e2e suite ever starts, and matrix fail-fast: true cancels the remaining shards the moment one goes red. Third, respect cost: parallelism and a matrix cut latency but each job bills its own minutes rounded up, Windows and macOS runners cost roughly 2× and 10× Linux, and a bigger runner only pays off for genuinely multi-core work — otherwise it sits idle billing more. Quarantine known-flaky tests into a non-blocking job so auto-retries never mask a real regression and the green check keeps meaning something. Now when you look at a slow pipeline, the first two questions are: does the key change when the lockfile changes, and does lint run before e2e?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.