Introduction
When building software that needs to work across multiple environments—different Node.js versions, operating systems, or configurations—testing each combination manually would be tedious. GitHub Actions' matrix strategy solves this by automatically generating multiple job instances from a single job definition.
This article explains how to leverage matrix builds for efficient parallel testing.
What Is Matrix Strategy?
A matrix creates multiple job runs by combining variables you define:
flowchart TB
subgraph Matrix["Matrix Definition"]
M["node: [18, 20, 22]<br/>os: [ubuntu, windows]"]
end
subgraph Jobs["Generated Jobs (6 total)"]
J1["ubuntu + node 18"]
J2["ubuntu + node 20"]
J3["ubuntu + node 22"]
J4["windows + node 18"]
J5["windows + node 20"]
J6["windows + node 22"]
end
Matrix --> Jobs
style Matrix fill:#3b82f6,color:#fff
style Jobs fill:#22c55e,color:#fff
Basic Matrix Syntax
Single Dimension Matrix
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 3 parallel jobs, one for each Node.js version.
Multi-Dimensional Matrix
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }} on ${{ matrix.os }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 9 jobs (3 OS Ă— 3 Node versions).
Matrix Configuration Options
Include: Adding Extra Combinations
Use include to add specific configurations that don't fit the matrix pattern:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
include:
# Add experimental Node 22 only on Ubuntu
- os: ubuntu-latest
node-version: 22
experimental: true
# Add extra environment variable for specific combo
- os: windows-latest
node-version: 20
npm-cache: 'C:\npm-cache'
Exclude: Removing Combinations
Use exclude to skip specific combinations:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [16, 18, 20]
exclude:
# Skip Node 16 on macOS (not supported)
- os: macos-latest
node-version: 16
# Skip Node 16 on Windows
- os: windows-latest
node-version: 16
Combining Include and Exclude
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 22
include:
- os: ubuntu-latest
node-version: 22
coverage: true
Failure Handling
fail-fast (Default: true)
By default, if any matrix job fails, all other jobs are cancelled:
strategy:
fail-fast: true # Default behavior
matrix:
node-version: [18, 20, 22]
Continue on Failure
Set fail-fast: false to let all jobs complete regardless of failures:
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
This is useful when you want to see all failing configurations.
continue-on-error for Experimental Builds
Mark specific jobs as allowed to fail:
jobs:
test:
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
node-version: [18, 20]
experimental: [false]
include:
- node-version: 23
experimental: true
Controlling Parallelism
max-parallel
Limit concurrent jobs to avoid overwhelming resources:
strategy:
max-parallel: 2
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
flowchart LR
subgraph Wave1["Wave 1"]
J1["Job 1"]
J2["Job 2"]
end
subgraph Wave2["Wave 2"]
J3["Job 3"]
J4["Job 4"]
end
subgraph Wave3["Wave 3"]
J5["Job 5"]
J6["Job 6"]
end
Wave1 --> Wave2 --> Wave3
style Wave1 fill:#3b82f6,color:#fff
style Wave2 fill:#8b5cf6,color:#fff
style Wave3 fill:#22c55e,color:#fff
Practical Examples
Cross-Platform Library Testing
name: Cross-Platform Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: macos-latest
node-version: 18
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 20
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
Database Version Testing
name: Database Compatibility
on: [push]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
database:
- postgres:14
- postgres:15
- postgres:16
- mysql:8.0
services:
db:
image: ${{ matrix.database }}
env:
POSTGRES_PASSWORD: postgres
MYSQL_ROOT_PASSWORD: mysql
ports:
- 5432:5432
- 3306:3306
options: >-
--health-cmd="pg_isready || mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: ${{ contains(matrix.database, 'postgres') && 'postgres://postgres:postgres@localhost:5432/test' || 'mysql://root:mysql@localhost:3306/test' }}
Build Configuration Matrix
name: Build Variants
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: web
build-cmd: npm run build:web
output-dir: dist/web
- target: electron
build-cmd: npm run build:electron
output-dir: dist/electron
- target: mobile
build-cmd: npm run build:mobile
output-dir: dist/mobile
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: ${{ matrix.build-cmd }}
- name: Upload ${{ matrix.target }} build
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.target }}
path: ${{ matrix.output-dir }}
Dynamic Matrix with JSON
Generate matrix values dynamically:
name: Dynamic Matrix
on: [push]
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
run: |
# Generate matrix from package.json or other source
PACKAGES=$(ls packages | jq -R . | jq -s -c .)
echo "matrix={\"package\":$PACKAGES}" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build --workspace=${{ matrix.package }}
Using Matrix Values
In Step Names
steps:
- name: Test on Node ${{ matrix.node-version }} / ${{ matrix.os }}
run: npm test
In Conditionals
steps:
- name: Windows-specific setup
if: matrix.os == 'windows-latest'
run: choco install some-package
- name: Deploy (only on latest Node)
if: matrix.node-version == 22
run: npm run deploy
In Artifact Names
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}-node${{ matrix.node-version }}
path: dist/
Best Practices
1. Start Small, Expand Gradually
# Start with essential combinations
matrix:
node-version: [18, 20] # LTS versions only
os: [ubuntu-latest] # Primary platform first
# Later expand to full matrix
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
2. Use fail-fast Strategically
| Scenario | fail-fast Setting |
|---|---|
| Quick feedback during development | true (default) |
| Full compatibility report | false |
| Release builds | false |
3. Optimize for Cost and Speed
strategy:
# Limit parallel jobs on self-hosted runners
max-parallel: 4
matrix:
os: [ubuntu-latest] # Linux is fastest and cheapest
node-version: [20] # Test primary version in PR
# Full matrix only on main branch
on:
push:
branches: [main]
4. Cache Per Matrix Configuration
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
Summary
| Feature | Purpose |
|---|---|
| Basic matrix | Test across multiple versions/configurations |
| include | Add specific combinations with extra properties |
| exclude | Remove unwanted combinations |
| fail-fast | Control whether to cancel on first failure |
| max-parallel | Limit concurrent job execution |
| continue-on-error | Allow experimental builds to fail |
Matrix strategy is essential for ensuring your code works across all target environments without maintaining separate workflow files for each combination.
References
- O'Reilly - Learning GitHub Actions, Chapter 13
- Packt - GitHub Actions Cookbook, Chapter 6
- GitHub Docs - Using a matrix for your jobs