Splitting a long pipeline into parallel jobs Jump to heading
Pipelines become serial the way code becomes tangled: one step at a time, each added where the previous one ended, without anyone asking whether it needed to wait. The result is a twenty-minute pipeline whose critical path is eight minutes, with twelve minutes of waiting for steps that had no dependency on each other. Splitting it is usually straightforward — the hard parts are not paying for dependency installation several times and knowing whether the change helped. This recipe covers both, within CI caching and runner performance.
When to use this approach Jump to heading
- Total pipeline duration is much larger than the longest single step.
- Lint, unit tests and build all run one after another with no real ordering need.
- One slow suite dominates and could be sharded.
- Runner capacity is available — parallelism you cannot schedule is not parallelism.
- If the pipeline is already at its critical path, splitting further adds overhead per job and makes it slower.
Step 1 — Draw the real dependency graph Jump to heading
Ask of each step: what does it read that a previous step wrote? Anything with no answer can start immediately.
# Current structure, in order
gh run view "$RUN_ID" --json jobs \
--jq '.jobs[] | {name, started: .startedAt, finished: .completedAt}' # The arithmetic that reveals the opportunity
gh run view "$RUN_ID" --json jobs --jq '
{wall: ((.jobs | map(.completedAt | fromdate) | max)
- (.jobs | map(.startedAt | fromdate) | min)),
serial_sum: ([.jobs[] | ((.completedAt | fromdate) - (.startedAt | fromdate))] | add)}' If wall and serial_sum are close, nothing is running in parallel. If wall is much smaller, you are already parallel and the remaining time is the critical path.
Step 2 — Install once and share the result Jump to heading
The naive split makes every job install dependencies, which can cost more than the serialisation it removed.
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- uses: actions/upload-artifact@v4
with: { name: node-modules, path: node_modules, retention-days: 1 }
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { name: node-modules, path: node_modules }
- run: npm run lint # Verification: only the setup job should show an install step taking real time
gh run view "$RUN_ID" --json jobs \
--jq '.jobs[] | {job: .name, steps: [.steps[] | select(.name | test("ci|install")) | .name]}' Whether artefact transfer beats a cache restore depends on the size of the tree; measure both. For large native dependency trees the cache is usually faster, and for small ones the artefact is.
Step 3 — Shard the suite that dominates Jump to heading
When one suite is the critical path, splitting the pipeline no longer helps — splitting the suite does.
test:
needs: setup
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npx jest --shard=${{ matrix.shard }}/4 --ci # Verification: shards should finish within a minute of each other
gh run view "$RUN_ID" --json jobs \
--jq '[.jobs[] | select(.name | startswith("test")) |
{name, seconds: ((.completedAt | fromdate) - (.startedAt | fromdate))}]' fail-fast: false matters here. With the default, one failing shard cancels the others and you learn about one failure per run instead of all of them, which turns a single fix into four round trips.
Step 4 — Avoid the traps that make parallel slower Jump to heading
Three patterns routinely make a split pipeline slower than the serial one it replaced.
# 1. Checkout in every job on a large repository — trim it
- uses: actions/checkout@v4
with: { fetch-depth: 1 }
# 2. Matrix jobs that each restore a large cache — measure the restore
# against simply running two shards in one job.
# 3. A fan-in job that waits for the slowest branch of the graph while
# holding a runner. Use `needs` precisely rather than listing everything. # Which job is the critical path?
gh run view "$RUN_ID" --json jobs \
--jq '[.jobs[] | {name, seconds: ((.completedAt | fromdate) - (.startedAt | fromdate))}]
| sort_by(-.seconds) | .[0:3]' Step 5 — Re-measure, and keep the measurement Jump to heading
The failure mode of pipeline optimisation is a restructure that feels faster and is not. Record the numbers before and after.
# Median duration over the last thirty runs, comparable before and after
gh run list --workflow ci.yml --limit 30 --json createdAt,updatedAt \
--jq '[.[] | ((.updatedAt | fromdate) - (.createdAt | fromdate))] | sort | .[15]' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
How many shards is too many? Jump to heading
When the per-shard fixed cost — runner acquisition, checkout, dependency restore — approaches the per-shard test time, adding shards stops helping and starts costing. For most suites that is somewhere between four and eight. Measure the shard duration spread; when the fastest shard is mostly setup, stop.
Should lint block the tests? Jump to heading
No. They read the same source and answer different questions, so running them in sequence adds latency without adding information. Both should gate the merge; neither should gate the other.
Does parallelism increase cost? Jump to heading
Total machine time goes up slightly because of duplicated setup, while wall-clock time goes down substantially. Whether that trade is right depends on whether you are paying for minutes or for people waiting — usually the second is far more expensive.
Related Jump to heading
- CI Caching & Runner Performance — the parent topic and the measurement discipline.
- Caching Dependencies Keyed on the Lockfile — making the shared setup job cheap.
- Batching Pull Requests to Cut CI Cost — the other half of the pipeline cost equation.