# GitHub Actions matrix parallel jobs: what each leg really costs

> A GitHub Actions matrix parallel fan-out repeats every fixed cost per leg. How the matrix expands, the ceilings, and where width stops paying.

Source: https://latchkey.dev/learn/speed/github-actions-matrix-strategy-parallel-jobs  
Updated: 2026-09-20

A GitHub Actions matrix parallel fan-out turns one job definition into one job per combination, up to 256 of them in a workflow run, and each of those jobs starts on a machine that has never seen your repository. On the runner we measured, getting to the first line of your own work cost 26.8 seconds, so a sixteen-way matrix spends more than seven minutes of billed time before any of it does anything useful.

A matrix is the cheapest parallelism in Actions to write and the easiest to over-buy. Four lines turn one job into twelve, and the wall clock improves by much less than twelve times because every leg pays the whole cost of being a job again.

This page is the three things worth knowing before you widen one: how the matrix expands, what caps it, and what each extra leg actually costs you in time and in money.

## How the matrix expands

Every key under `matrix` is a list, and the matrix is the product of all of them. Two operating systems and three Node versions is six jobs, not five and not two. Add a fourth Node version and you have eight, and the workflow file grew by one word.

`include` adds combinations, or adds keys to combinations that already exist, and `exclude` removes them, which is how you get "every version on Linux, but only the newest on macOS" without writing the product out. GitHub caps the result: a job matrix can generate a maximum of 256 jobs per workflow run, on hosted and self-hosted runners alike.

```.github/workflows/ci.yml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: [20, 22, 24]
    exclude:
      - os: macos-latest
        node: 20
    include:
      - os: ubuntu-latest
        node: 24
        coverage: true
```

> Six combinations, minus one excluded, is five jobs; the `include` entry adds a key to a combination that already exists rather than creating a sixth. Read the expanded list in the run summary rather than in your head.

## fail-fast and max-parallel are the two dials

`fail-fast` is on by default, and it means the first leg to fail takes the rest with it: everything in progress or queued in that matrix is stopped. Right for a gate you need only a yes or no from, wrong for a test matrix, where you wanted to know whether the failure is one version or all of them. Turning it off costs the minutes the other legs run and buys the answer in one run instead of two.

`max-parallel` limits how many legs run at once without changing how many run in total. It is the dial for a matrix that hammers a shared resource: a staging database, a rate-limited API, a license server. It is also how you stop one workflow eating your whole concurrency allowance while everything else in the organization queues behind it.

```.github/workflows/ci.yml
strategy:
  fail-fast: false      # every leg reports, even after one fails
  max-parallel: 4       # at most four at a time, all of them eventually
  matrix:
    shard: [1, 2, 3, 4, 5, 6, 7, 8]
```

## The ceiling is your plan, not your YAML

A matrix of 40 legs on a plan that allows 20 concurrent jobs is not 40 jobs in parallel. It is 20, then 20 more, and your wall clock is two rounds rather than one. The limit is per account across every workflow you are running, so the other pull requests open at the same time are spending the same budget.

The macOS numbers are the ones that surprise people, because they are small and they do not scale with the plan until Enterprise. A five-way macOS matrix is the entire allowance on Free, Pro and Team.

| Plan | Concurrent jobs | Concurrent macOS jobs |
| --- | --- | --- |
| Free | 20 | 5 |
| Pro | 40 | 5 |
| Team | 60 | 5 |
| Enterprise | 500 | 50 |

> Quoted from the GitHub Actions limits reference, read on 2026-09-20 and linked below. Larger runners are counted separately, at 1,000 concurrent jobs on Team and Enterprise. A job on a hosted runner can run for up to 6 hours, and a workflow run for up to 35 days.

## Every leg pays the fixed cost again

This is the part the graph does not show you. Each leg queues for its own runner, checks out again, installs its toolchain again and restores its dependencies again. We measured those costs on a runner one at a time, and the three that a typical job pays before its own first command add up to 26.8 seconds.

Multiply that by the width, because a matrix does. Two legs is 54 seconds of setup, eight legs is three and a half minutes, sixteen is over seven. None of it is your build, all of it is billed, and rounding makes it worse: GitHub rounds each job up to the nearest minute, so sixteen legs that each finish in forty seconds are billed as sixteen minutes.

| Fixed cost, measured once | Duration | Paid per leg |
| --- | --- | --- |
| `apt-get update` | 23.85 s | Yes, unless it is in the image |
| Extract a toolchain not in the tool cache | 2.16 s | Yes, on every leg |
| `git clone --depth 1`, the checkout default | 0.83 s | Yes, on every leg |
| A toolchain already in the tool cache | 0.009 s | Yes, and it is free |
| `npm ci`, 414 packages, cache restored | 2.39 s | Yes, on every leg |

> Measured by `job-f.sh` and `job-a.sh`, under content/repro/timings/, on Latchkey `latchkey-small` runners on 2026-09-20, one pass per row. This page quotes those logs rather than running them again. The 26.8 second figure is the first three rows added together, which is the shape of a job that installs a system package and a toolchain the image does not carry; a leg that avoids both pays about 3 seconds instead.

## Where width stops paying

The arithmetic is not complicated and almost nobody does it. Wall clock for a matrix is the fixed cost plus the work divided by the width. Billed time is the fixed cost times the width, plus the work. The fixed cost is the only term that grows with width, and it grows linearly while the saving shrinks. Count queue time inside it: a leg that waits for a runner is a leg that has not started, and once the width crosses your plan ceiling the later legs wait for the earlier ones to finish.

Put your own two numbers in. If setup is 30 seconds and the work is 30 seconds, going from one leg to four takes the wall clock from 60 seconds to 37.5 and the bill from 60 seconds to 150. If setup is 30 seconds and the work is 20 minutes, the same change is obviously worth it. The question is never "is a matrix good", it is "is my work large compared with my setup".

```The arithmetic, with queue time inside setup
# setup       = queue + checkout + toolchain + install
# wall clock  = setup + work / legs
# billed time = setup * legs + work
#
# leg 2 removes work/2. leg 16 removes work/240.
# every leg adds the same setup. that is the whole knee.
```

## Make the legs cheaper before you add more

Every second off the fixed cost is multiplied by the width, which makes setup the highest-leverage thing in a wide matrix. The one that dominated our measurements is a system package install: an index refresh alone was 23.85 seconds, and moving those packages into a prebuilt image removes it from every leg at once.

After that, ask each leg for a toolchain version the runner image already carries, which was 9 milliseconds against 2.16 seconds for one it has to unpack, and cache dependencies so the install is a restore. Then narrow the matrix itself: most matrices carry combinations nobody reads the result of, and an `exclude` is free speed.

```.github/workflows/ci.yml
jobs:
  test:
    container: ghcr.io/acme/ci-base:2026-09   # no apt-get on any leg
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
          cache: npm
```

## What we ran, so you can disagree with it

No new runner jobs were run for this page. The per-leg costs are quoted from two scripts committed for earlier pages in this area, `job-f.sh` and `job-a.sh`, both run once on Latchkey `latchkey-small` runners on 20 September 2026 and both committed with their unedited logs and a status file carrying the job id, the runner size and the exit code.

The multiplication is arithmetic on those measurements and is not itself a measurement: we did not run a sixteen-way matrix and time it. A real matrix also pays queue time per leg, which varies with your plan and with what else is running, and which nothing in this page measures. And the `apt-get install` row in that log installed nothing, because the package was already on the image, so it measures the cost of asking rather than the cost of installing.

## FAQ

### How many jobs can a GitHub Actions matrix create?

A job matrix can generate a maximum of 256 jobs per workflow run, and GitHub documents that limit as applying to hosted and self-hosted runners alike. How many of those run at once is a separate limit: your plan allows 20 concurrent jobs on Free, 40 on Pro, 60 on Team and 500 on Enterprise, shared across everything you have running.

### What does fail-fast do in a matrix strategy?

It is on by default, and it stops every other leg of that matrix as soon as one fails, including legs already running. For a test matrix set it to false, because the useful information is whether the failure is one version or all of them, and the default hides that until you rerun.

### How do I limit how many matrix jobs run at once?

Use `max-parallel`, which caps how many legs run simultaneously without reducing how many run in total. It is the right tool when the legs share something that cannot take the load, such as a staging database or a rate-limited API, and it also keeps one workflow from consuming the whole account concurrency allowance.

### Why is my matrix slower than the number of legs suggests?

Because only your own work divides across the legs and the fixed cost repeats in full. On the runner we measured, a leg that refreshes a package index, unpacks a toolchain and checks out pays 26.8 seconds before its first command. Add your concurrency ceiling on top: a 40-leg matrix on a plan allowing 20 concurrent jobs runs in two rounds.

## References

- [GitHub Docs: running variations of jobs in a workflow (verified 2026-09-20)](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations)
- [GitHub Docs: Actions limits, concurrency and the matrix cap (verified 2026-09-20)](https://docs.github.com/en/actions/reference/limits)
- [GitHub Docs: workflow syntax, jobs and strategy (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: contexts, including the strategy context (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
