Matrix builds are the CI equivalent of a "just add water" recipe. You write six lines of YAML, and suddenly your one job becomes twelve. It feels like a superpower the first time you use it โ right up until your PR checks take eighteen minutes, half of them are red for reasons that have nothing to do with your code, and someone in finance asks why the Actions bill tripled.
I've watched this exact arc play out on a real pipeline at Cubet: a "quick matrix to test a few Node versions" quietly turned into 24 jobs per push because nobody did the multiplication before hitting commit. Let's talk about how matrices actually explode, and the handful of guardrails that keep them from doing it.
The multiplication nobody does in their head
Here's the innocent-looking config that started it:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
include:
- os: ubuntu-latest
node: 22
experimental: true
That's 3 ร 3 = 9 base jobs, plus whatever the include adds. Now multiply by however many workflows in your repo reuse this pattern (lint, test, build, integration), and by however many times a day people push to open PRs. Nine jobs isn't scary. Nine jobs times four workflows times thirty pushes a day is 1,080 job runs โ and macOS runners bill at roughly 10x the Linux rate.
The failure mode isn't "the matrix is wrong." It's that nobody asked whether every cell in that grid is actually load-bearing. Do you really need to verify Node 18 and 22 on Windows and macOS, or do you need Linux coverage across Node versions and OS coverage on one pinned Node version?
Fix 1: separate "what varies" from "what's just expensive to prove"
Most teams don't need a full cross product. They need two axes tested independently:
strategy:
fail-fast: false
matrix:
include:
# Node version coverage โ cheapest runner
- os: ubuntu-latest
node: 18
- os: ubuntu-latest
node: 20
- os: ubuntu-latest
node: 22
# OS coverage โ pin one Node version
- os: windows-latest
node: 20
- os: macos-latest
node: 20
Five jobs instead of nine, and every job is answering a distinct question instead of re-proving "Node 18 also works on Windows," which almost nobody's bug reports ever hinge on. When you're defining a matrix, ask what specific regression each cell would catch that no other cell catches. If you can't answer that, delete the cell.
Fix 2: fail-fast: false is not free, and neither is the default
By default, fail-fast: true means the instant one matrix cell fails, GitHub cancels the rest. That sounds efficient, but it's a trap in exactly the situation matrices are supposed to help with: distinguishing "my code is broken everywhere" from "my code is broken only on Windows." With fail-fast on, a single flaky Windows runner cancels your macOS and Linux jobs mid-flight, and you re-run the whole matrix to get signal you already half-had.
Flip it explicitly for anything where partial results matter:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
But do the opposite for fast-feedback lint/typecheck jobs that aren't matrixed by environment โ there, cancel-on-first-failure is exactly what you want, because there's no "partial signal" to preserve.
Fix 3: cap concurrency, don't let the queue self-DDoS
max-parallel gets ignored constantly because the default (unlimited, up to your plan's concurrency cap) feels generous โ until ten contributors push in the same ten minutes and your entire matrix fleet is queued behind the org's runner limit, and nothing finishes because every workflow is holding a partial allocation.
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
This isn't about making one workflow faster โ it's about making sure one huge PR doesn't starve every other PR's checks out of runners for twenty minutes.
Fix 4: exclude the cells you don't need, don't just accept them
exclude is the most underused key in the matrix vocabulary:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # EOL'd before our macOS support window opened
- os: windows-latest
node: 18 # same story
If your minimum supported Node version only matters on Linux (because that's what prod runs), don't pay for it on the two most expensive runner types too. Every exclude line is a line item off your bill, and it documents why that combination doesn't matter โ which is more useful than a matrix that silently tests everything "just in case."
Fix 5: matrix your tests, not your setup
The most expensive mistake is repeating slow setup work in every matrix cell instead of once. If each of your nine jobs does a fresh npm install with no cache hit, you're paying nine times for the same dependency resolution.
jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
cache: 'npm' alone can cut minutes off every cell, and because it's per-Node-version keyed, it doesn't cross-contaminate between matrix legs. It's a two-line change that pays for itself on the very next run.
The actual lesson
A matrix isn't a stress test for your CI budget โ it's a hypothesis about which environment combinations can independently fail. Every cell you add should map to a real question ("does this break on Windows specifically?"), not a reflex ("more coverage is always better"). Before you add a dimension, ask what bug report you're trying to preempt, and whether a narrower grid โ or a separate single-job workflow that only runs nightly โ answers it just as well for a fraction of the runner-minutes.
Go open your busiest workflow file right now and count the matrix cells. If you can't explain what unique failure each one catches, you've found your first PR of the week โ and possibly your first line item to strike from next month's Actions bill.