needs is a rendezvous point — and every one you add can slow CI down

GitHub Actions’ needs keyword creates a dependency (a rendezvous) between jobs. That’s useful, but it comes with a cost: every needs you write is a potential source of added CI time. Teams tend to write needs conservatively, and jobs that could run in parallel end up serialized far more often than necessary.

This article is a pattern-and-anti-pattern reference centered on needs. Check your own YAML against each pattern as you read.

Pattern cheat sheet

PatternEffectWhen to use it
Diamond-shaped fan-outParallel execution cuts CI timelint/test/typecheck don’t depend on each other
concurrency groupsAutomatically cancels stale runsConsecutive pushes to the same PR
fail-fast: falseOne failure doesn’t take the rest down with itYou want results from every matrix combination
Reusable workflows (workflow_call)Eliminates duplicated definitionsThe same job sequence is repeated across workflows/repos
if: always() notification jobGuarantees notification even on failureSlack alerts, status reports
Partial matrix waitingDeploy early from a subset of combinationsCanary-style early rollout

Let’s walk through each with a concrete example.

Pattern 1: break up serialization with a diamond shape

The most common anti-pattern is lining up jobs that are actually independent.

# Anti-pattern: lint -> unit-test -> typecheck -> build, all serial
jobs:
  lint:
    runs-on: ubuntu-latest
  unit-test:
    needs: lint
  typecheck:
    needs: unit-test
  build:
    needs: typecheck

unit-test and typecheck share no precondition beyond “the code is there.” If both only need lint to finish, hang them both directly off lint and form a diamond.

# Better: unit-test and typecheck run in parallel after lint
jobs:
  lint:
    runs-on: ubuntu-latest
  unit-test:
    needs: lint
  typecheck:
    needs: lint
  build:
    needs: [unit-test, typecheck]   # waits for both

Making build’s needs an array expresses “run once both are done.” Same number of jobs, but the width of the dependency graph — its parallelism — directly shortens CI time. Paste the workflow into the GitHub Actions Visualizer and this kind of unnecessary serialization jumps out of the diagram immediately.

Pattern 2: cancel the previous run with concurrency

Even a well-designed needs graph doesn’t help if stale runs pile up in the queue from consecutive pushes to the same branch. concurrency eliminates that waste.

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

Including github.ref (branch name or PR number) in group means a new push to the same branch automatically cancels the outdated run. Push three times in a row to a PR, and ideally only the last, meaningful run makes it to completion.

:::message
If group doesn’t also include the job or workflow name, unrelated workflows can end up canceling each other unintentionally. A pattern like ${{ github.workflow }}-${{ github.ref }} scopes the group per workflow and is the safer default.
:::

Pattern 3: choosing fail-fast deliberately

strategy.matrix expands one job definition into multiple runs (Node 18/20/22 × 3 OSes, for example). How you set fail-fast here has a big effect on CI behavior.

strategy:
  fail-fast: false   # default is true
  matrix:
    node: [18, 20, 22]
    os: [ubuntu-latest, windows-latest, macos-latest]
SettingBehaviorGood for
fail-fast: true (default)Any single failing combination cancels the rest immediatelyFast feedback — once you know it failed, more data isn’t needed
fail-fast: falseAll combinations run to completion even if one fails”Is it just Node 20, or does OS matter too?” — you want the full picture in one CI run

Before a release, when you want to see the status of every combination at once, false earns its keep. For everyday PR checks where speed matters most, leave the default true and prioritize fast failure detection.

Pattern 4: the trap of waiting on a matrix job with needs

When build depends on test (which is matrix-expanded), writing needs: test correctly waits for every combination in the matrix to finish. That part is intuitive. Two things aren’t:

  • If even one combination fails, the dependent job is skipped too — especially with fail-fast: true, downstream work can stop based on a partial result
  • If you want only some combinations to proceed early (e.g., deploy Linux immediately, check Windows/macOS results later), needs alone can’t express that — you have to split the job itself
# Splitting jobs so Linux can deploy ahead of the rest
jobs:
  test-linux:
    runs-on: ubuntu-latest
  test-other-os:
    strategy:
      matrix:
        os: [windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
  deploy:
    needs: test-linux   # doesn't wait on Windows/macOS

The key takeaway: “wait for everything, or wait for part of it” is decided by how you split jobs, not by how you write needs.

Pattern 5: share the dependency graph itself via reusable workflows

If the same job sequence gets reused across multiple workflows — or multiple repositories — workflow_call lets you centralize it in one place.

# .github/workflows/ci.yml (caller)
jobs:
  call-shared-ci:
    uses: ./.github/workflows/shared-test-suite.yml
    with:
      node-version: '20'

The needs structure inside the called workflow (shared-test-suite.yml) is reused wholesale, so you don’t have to copy-paste the “lint depends on test” relationship across repos. Once you’ve settled on a dependency-graph pattern, this is the finishing move: turning the design itself into a reusable component.

Keep the notification job outside needs’ failure propagation

Because of how needs works, a notification job gets skipped automatically if the job it depends on fails. Notifications are the one thing you want to fire regardless, so mark it explicitly with if: always().

notify:
  needs: [lint, test, build]
  if: always()   # always runs, regardless of upstream success/failure
  runs-on: ubuntu-latest
  steps:
    - run: echo "Result: ${{ needs.build.result }}"

needs.<job>.result exposes each dependency’s outcome (success / failure / cancelled / skipped), so you can build a notification message that says exactly what failed.

A checklist for auditing your dependency graph

  1. Paste your workflow YAML into the GitHub Actions Visualizer to see the dependency graph
  2. Look for stretches that are serialized in a straight line and ask whether the ordering is actually required (Pattern 1)
  3. If duplicate runs from consecutive pushes bother you, add concurrency (Pattern 2)
  4. For matrix jobs, check whether fail-fast matches your actual goal (Pattern 3)
  5. If the same dependency structure repeats across workflows, consider extracting a reusable workflow (Pattern 5)

CI runtime is often better spent fixing the shape of the dependency graph than trimming individual steps. Diagram your workflow once and look for unnecessary serialization.

FAQ

Does adding more needs always make CI slower?

Not needs itself — the problem is writing a dependency that doesn’t actually need to wait. A needs that waits on a genuine prerequisite (a built artifact, say) is necessary and shouldn’t be removed. What’s worth revisiting is the unnecessary chain — e.g., waiting for an entire previous job to finish when only lint completing was actually required.

Should concurrency’s cancel-in-progress always be true?

For CI triggered by ongoing PR pushes, generally yes. But for deployment workflows that run after merging to main, cancellation risks leaving a deploy in a half-finished state — consider cancel-in-progress: false, or skip concurrency on deploy workflows entirely.

Does fail-fast: false make CI take longer?

It can. fail-fast: true (the default) cuts the remaining jobs at the first failure, which tends to shorten average CI time. Reserve false for situations where you genuinely need results from every combination — pre-release environment checks, for example.

Is the workflow YAML I use to review dependencies sent anywhere?

No. The GitHub Actions Visualizer processes everything in your browser, so pasting in internal workflow definitions never sends them to a server.