GitHub Actions Matrix Build: Multi-Version Parallel Testing in Practice
Last week, right after our project went live, we got a user report: pages were completely blank in Node 16. My heart sank.
We spent two hours debugging. Dug through logs, compared code across three rounds, and finally discovered the issue: a specific API handled JSON.stringify() differently in Node 16 versus Node 20. The older version threw an error on circular references, while the new version handled them silently. Our CI pipeline only tested Node 20, so this compatibility issue slipped through entirely.
During the post-mortem, I kept thinking: if we had set up multi-version parallel testing, this would’ve been caught before deployment. That incident pushed me to dive deep into GitHub Actions Matrix builds. The term sounds intimidating, but it’s fundamentally simple: automatically splitting one job into multiple instances that run simultaneously across different versions and platforms.
In this article, I’ll walk you through Matrix from basics to advanced techniques. We’ll cover core syntax, exclude/include filter combinations, fail-fast strategy choices, and max-parallel resource control. By the end, you’ll have a production-ready Node.js multi-version testing template you can copy directly into your project. About 10 minutes of reading, and you’ll be ready to upgrade your CI to parallel multi-version testing.
Matrix Basics — Up and Running in 5 Minutes
Matrix can be understood in one sentence: you write one job, and GitHub Actions automatically expands it into multiple parallel tasks.
Here’s an example. You define three Node.js versions [18, 20, 22], and Matrix creates three independent test tasks running on Node 18, Node 20, and Node 22 respectively. These tasks launch simultaneously, execute in parallel, and don’t interfere with each other.
The simplest configuration looks like this:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
Focus on the strategy.matrix section. node-version is a variable name you define yourself, and the array [18, 20, 22] contains the possible values. GitHub Actions iterates through this array, assigning each value to matrix.node-version and creating a corresponding job instance.
The ${{ matrix.node-version }} syntax references the current value. On the first execution it’s 18, then 20, then 22.
When I first used this, I had a question: do these tasks run serially or in parallel? The answer is they run in parallel by default. Push code once, and GitHub launches three Runners simultaneously—three tasks running together. In practice, serial testing across three versions took 15 minutes. With Matrix, it dropped to 5 minutes—because tasks run concurrently, the total time is limited by the slowest task.
One caveat: parallel execution means more Runner minutes consumed. Three tasks means three times the minutes. If you’re using the free tier (2000 minutes per month), large matrices can burn through your quota quickly. We’ll cover this in the max-parallel section.
exclude/include Filtering — Fine-Grained Matrix Control
When you start combining multiple dimensions, Matrix combinations grow exponentially.
For example, three Node versions [16, 18, 20] and three operating systems [ubuntu, windows, macos] combine to 3 x 3 = 9 tasks. Add test suites [unit, integration, e2e], and you get 27. Is that a lot? For open source projects, maybe not. But for small teams, Runner minutes translate directly to cost.
Plus, some combinations are meaningless. Node 16 is already EOL (End of Life)—testing it on Windows and macOS is a waste of time. This is where exclude comes in to filter out those combinations.
strategy:
matrix:
node-version: [16, 18, 20]
os: [ubuntu-latest, windows-latest, macos-latest]
exclude:
- node-version: 16
os: windows-latest
- node-version: 16
os: macos-latest
The exclude syntax lists combinations to remove. The configuration above deletes Node 16 + Windows and Node 16 + macOS combinations. Originally 9 tasks become 7—a 22% reduction in Runner minutes.
include works the opposite way—adding specific combinations or extra variables. For example, if you want to add a Node 23 experimental version test, but only on Ubuntu:
strategy:
matrix:
node-version: [16, 18, 20]
os: [ubuntu-latest, windows-latest]
include:
- node-version: 23
os: ubuntu-latest
experimental: true
Here’s a key detail: include doesn’t just add combinations, it can also attach extra variables to specific combinations. The experimental: true above only exists in the Node 23 task. You can check this variable in subsequent steps—for instance, allowing experimental version failures without blocking the entire workflow:
- name: Run tests
run: npm test
continue-on-error: ${{ matrix.experimental == true }}
I made a mistake with exclude and include priority: GitHub Actions executes include to add combinations first, then exclude to remove them. So if you include a combination and then exclude it, that combination won’t appear. Get the order wrong, and results might not match your expectations.
To summarize:
exclude: Remove unnecessary combinations—saves money and timeinclude: Add special combinations, plus attach extra variables for differentiated handling
fail-fast and max-parallel — Tuning Parallel Strategy
Matrix has a default behavior you might not notice: fail-fast: true.
What does this mean? If any task in the matrix fails, GitHub Actions immediately cancels all other running tasks. For example, with 10 parallel tasks, if task 3 fails after one minute, the remaining 7 tasks are terminated instantly.
Is this good or bad? It depends on your scenario.
For PR checks, fail-fast is beneficial. Someone submits code, and Node 18 tests fail—you don’t need to wait for other versions to finish. Feedback goes directly to the author to fix it immediately. Saves time, saves resources.
But for Nightly tests or scheduled regression runs, fail-fast might be counterproductive. You want a complete test report—which versions have issues, which don’t. If Node 18 fails and stops other tasks, you’ll never know if Node 20 has the same problem. In this case, set fail-fast: false.
strategy:
fail-fast: false
matrix:
node-version: [16, 18, 20]
max-parallel controls how many tasks run simultaneously. The default is unlimited—GitHub launches all tasks at once. But for large matrices, say 30 combinations, you might not want to consume all Runner resources at once.
strategy:
fail-fast: true
max-parallel: 6
matrix:
node-version: [16, 18, 20, 22]
test-suite: [unit, integration, e2e]
The configuration above limits concurrent tasks to 6. 30 combinations execute in batches of 6. The benefit is controllable Runner resources—no sudden quota exhaustion. The downside is longer total time.
I’ve put together a simple decision table for different scenarios:
| Scenario | fail-fast | max-parallel | Reason |
|---|---|---|---|
| PR checks | true | unlimited | Quick feedback—one failure stops everything, saves time |
| Nightly tests | false | 4-6 | Collect complete problem reports, identify all bugs |
| Large matrix (>20 combinations) | true | 4 | Limit resource consumption, avoid quota explosion |
| Experimental version testing | false | unlimited | Experimental failures don’t affect overall judgment |
Honestly, the default fail-fast: true works for most cases. Only change it to false when you need complete diagnostics. max-parallel has minimal impact on small matrices (under 10)—it only matters for large ones.
One important note: max-parallel limits the number of tasks GitHub Actions launches simultaneously, not the number of Runners. If you’re using self-hosted runners, setting this too low causes queue delays that slow overall time. For public Runners, this setting is meaningful.
Complete Production Template — Node.js Multi-Version Parallel Testing Pipeline
The previous sections were scattered knowledge points. This chapter gives you a complete, copy-paste-ready configuration template.
This template includes:
- Three Node versions (16, 18, 20)
- Two test suites (unit and integration)
- Two operating systems (Ubuntu and Windows)
- Automatic caching to speed up dependency installation
- Excludes Node 16 Windows tests (EOL version)
name: Multi-Version Test Matrix
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 6
matrix:
node-version: [16, 18, 20]
test-suite: [unit, integration]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: 16
os: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ${{ matrix.test-suite }} tests
run: npm run test:${{ matrix.test-suite }}
Let me highlight a few key points:
runs-on: ${{ matrix.os }}: The operating system is also dynamic—each task selects the corresponding Runner based on the matrix combination.
cache: 'npm': This is setup-node’s built-in caching feature. It caches npm dependencies based on the hash of package-lock.json. On the second run, it reuses the cache without re-downloading. In practice, this reduces dependency installation time by over 50%.
fail-fast: false: Deliberately set to false here, because multi-version testing aims to discover all problems. One version fails, others keep running.
npm run test:${{ matrix.test-suite }}: Assuming your package.json defines test:unit and test:integration commands, Matrix calls them separately.
How many tasks does this configuration generate?
3 versions x 2 test suites x 2 operating systems = 12 tasks, minus the excluded Node 16 + Windows (2 test suites), leaving 10 tasks.
Real-world results: I’ve run this configuration across several projects. With caching, CI time dropped from 25 minutes serial to around 8 minutes. The savings come from parallel execution and dependency caching.
For larger projects with more combinations, consider:
- Increase
max-parallellimit (e.g., 8 or 10) - Split e2e tests into a separate job to avoid slowing things down
- Use
continue-on-errorto handle experimental version failures
Copy this template to your .github/workflows/test.yml, adjust version numbers and test suite names based on your situation, and you should be ready to go.
Conclusion
Let’s recap the key points:
Matrix essentially expands one job into multiple parallel tasks automatically. Simple configuration, direct results—push once, three versions run simultaneously, CI time compresses to the slowest task’s duration.
exclude/include provides fine-grained control. When combinations multiply, use exclude to remove unnecessary tasks and save 20%+ Runner minutes. include adds special combinations and can attach extra variables for differentiated handling.
fail-fast defaults to true—one failure stops all tasks. Use the default for PR checks. For Nightly tests, set to false for complete reports. max-parallel controls concurrency limits—only relevant for large matrices.
Caching is essential. setup-node’s built-in cache—one line of configuration cuts dependency installation time in half.
Next steps: Copy the complete template above to your project’s .github/workflows/ directory. Start with Node.js three-version testing. Once confirmed working, gradually expand to multiple platforms and test suites. If you run into caching configuration issues, check out the series article “GitHub Actions Caching Strategies: Speed Up Your CI/CD Pipeline 5x” for more detailed techniques.
Set up multi-version testing early. Don’t wait until deployment problems hit—that blank screen bug still gives me a headache when I think about it.
Configure GitHub Actions Matrix Multi-Version Testing
Build a multi-version parallel testing pipeline from scratch, covering Node.js 16/18/20 and Ubuntu/Windows platforms
⏱️ Estimated time: 15 min
- 1
Step1: Create Workflow File
Create `.github/workflows/test.yml` in your project root:
• Ensure correct directory structure: `.github/workflows/`
• Filename is customizable—recommend `test.yml` or `ci.yml` - 2
Step2: Configure Trigger Conditions
Define when tests should run:
```yaml
on:
push:
branches: [main]
pull_request:
```
• Trigger on main branch pushes
• Trigger on PR creation or updates - 3
Step3: Define Matrix
Configure versions, platforms, and test suites:
```yaml
strategy:
fail-fast: false
max-parallel: 6
matrix:
node-version: [16, 18, 20]
test-suite: [unit, integration]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: 16
os: windows-latest
```
• fail-fast: false to collect complete test results
• max-parallel: 6 to control concurrency limit
• exclude to filter invalid combinations - 4
Step4: Configure Test Steps
Define specific test execution steps:
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:${{ matrix.test-suite }}
```
• cache: 'npm' enables dependency caching
• Use matrix variables for dynamic version configuration - 5
Step5: Commit and Verify
Push code to trigger tests:
• Commit to main branch or create PR
• Check parallel task execution in GitHub Actions dashboard
• Verify test results across all versions are normal
FAQ
Does Matrix build consume more Runner minutes?
Should fail-fast be true or false?
• PR checks: Recommend true (fast fail, immediate feedback)
• Nightly tests: Recommend false (collect complete reports)
• Experimental versions: Recommend false (avoid affecting overall judgment)
Default is true, which works for most PR scenarios.
Which executes first: exclude or include?
What's a good max-parallel setting?
• Small matrix (<10 combinations): No need to set, use default
• Medium matrix (10-20 combinations): Set to 6-8
• Large matrix (>20 combinations): Set to 4-6
Setting too low extends total wait time. Setting too high may exhaust Runner resources at once.
How to use caching in Matrix for acceleration?
What variable types does Matrix support?
• Array: `[18, 20, 22]`
• Array of objects: `[{name: 'a', value: 1}, {name: 'b', value: 2}]`
• String: Requires include to add
Recommend arrays and object arrays for better readability.
9 min read · Published on: Apr 8, 2026 · Modified on: Apr 8, 2026
Related Posts
Supabase Auth in Practice: Email Verification, OAuth & Session Management
Supabase Auth in Practice: Email Verification, OAuth & Session Management
GitHub Actions Cache Strategy: Speed Up CI/CD Pipeline 5x
GitHub Actions Cache Strategy: Speed Up CI/CD Pipeline 5x
GitHub Actions Deployment Strategies: From VPS to Cloud Platforms CD Pipeline

Comments
Sign in with GitHub to leave a comment