Tools mentioned in this article
Open the browser-based tool while you read and try the workflow immediately.
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
| Pattern | Effect | When to use it |
|---|---|---|
| Diamond-shaped fan-out | Parallel execution cuts CI time | lint/test/typecheck don’t depend on each other |
concurrency groups | Automatically cancels stale runs | Consecutive pushes to the same PR |
fail-fast: false | One failure doesn’t take the rest down with it | You want results from every matrix combination |
Reusable workflows (workflow_call) | Eliminates duplicated definitions | The same job sequence is repeated across workflows/repos |
if: always() notification job | Guarantees notification even on failure | Slack alerts, status reports |
| Partial matrix waiting | Deploy early from a subset of combinations | Canary-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]
| Setting | Behavior | Good for |
|---|---|---|
fail-fast: true (default) | Any single failing combination cancels the rest immediately | Fast feedback — once you know it failed, more data isn’t needed |
fail-fast: false | All 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),
needsalone 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
- Paste your workflow YAML into the GitHub Actions Visualizer to see the dependency graph
- Look for stretches that are serialized in a straight line and ask whether the ordering is actually required (Pattern 1)
- If duplicate runs from consecutive pushes bother you, add
concurrency(Pattern 2) - For matrix jobs, check whether
fail-fastmatches your actual goal (Pattern 3) - 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.