---
title: "Run Independent GitHub Actions Jobs in Parallel | StarSling"
description: "Find workflow_run chains and max-parallel: 1 matrices that serialize independent GitHub Actions work, and fix both without losing real ordering guarantees."
url: https://starsling.dev/github-actions/optimizations/parallelize-independent-jobs
canonicalUrl: https://starsling.dev/github-actions/optimizations/parallelize-independent-jobs
---

# Run independent GitHub Actions jobs in parallel

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Run independent GitHub Actions jobs in parallel

- [How StarSling works](https://starsling.dev/)

Rule: ci.parallel.independent-jobs. Detection mode: static. Last updated: 2026-08-21

Two configuration shapes turn parallel work sequential without any real dependency requiring it: a second workflow gated behind workflow_run when nothing in it needs the first workflow's result, and a matrix strategy throttled with max-parallel: 1 so every variant waits for the one before it.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

workflow_run and max-parallel exist for real cases: a deploy that must follow a successful build, or a shared resource a matrix cannot hit concurrently. The problem is when either is applied where nothing forces it. A workflow_run chain adds a full second workflow dispatch on top of the wait - GitHub has to detect the completion event, evaluate the trigger, and queue a fresh run - so the two workflows do not just run one after another, they run one after another plus that queue latency, for work that could have been two jobs in one job graph finishing at the same time. A max-parallel: 1 matrix is worse per variant: a matrix exists specifically to fan work out, and pinning it to one turns that fan-out into a for-loop, so the wall-clock cost of the slowest single variant becomes the cost of every variant added together.

_.github/workflows/ci.yml_

```yaml
name: CI
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/build.yml_

```yaml
name: Build
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Search for workflow_run triggers: `grep -rln 'workflow_run' .github/workflows/`.
2. For each match, read the workflow it names in `workflows:` and open the upstream workflow it runs after. Check whether the triggered workflow reads anything the first workflow produced (build artifacts, a version bump, a tag) via actions/download-artifact, a checkout of a ref the first workflow created, or an output the first workflow set.
3. If nothing is read across the boundary, the chain is a serialization artifact, not a real dependency, and the workflows are a candidate to merge into one workflow with a job graph.
4. Search for throttled matrices: `grep -rn 'max-parallel' .github/workflows/`.
5. For each hit, read the job's matrix and the steps that run inside it. Look for a resource the steps genuinely share and cannot use concurrently: a single deployment target, a rate-limited external API key, one database the job writes to. If nothing in the steps references a shared, non-concurrent-safe resource, the throttle has no matching justification in the workflow.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- workflow_run is the right tool when the second workflow truly needs the first one's output - a signed artifact, a version it bumped, a status only computable after the build finishes. Consolidating those into one workflow is correct too, using needs: between jobs (see the sibling recipe on spurious needs:), but if they must stay separate workflows (different triggers, different permission scopes, a reusable workflow shared across repos), keep workflow_run and document what it is waiting for.
- max-parallel: 1 is sometimes the honest answer to a real constraint: a matrix that all deploy to the same staging environment, a matrix that all call a third-party API with a strict per-second rate limit, or a matrix that all write to one shared test database. In those cases the fix is not removing the throttle, it is narrowing it - move the shared resource into its own serialized job or a concurrency group scoped to that resource, and let everything else in the matrix run in parallel.
- Raising max-parallel above 1 without checking for a shared resource can produce flaky failures that look unrelated to the change (two matrix legs racing to write the same file, deploy to the same slot, or exhaust a shared rate limit). Increase it, watch several runs, and check for exactly that failure signature before calling it done.
- Merging two workflows into one changes their trigger surface: a workflow_run consumer often runs with different permissions or a different trigger (e.g. it only fires after a push-triggered workflow succeeds, never on pull_request). Confirm the merged workflow's triggers and permissions still match what each half needed before deleting the original files.

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's GitHub Actions workflows for serialization that has no matching dependency and fix what you find.

1. Run `grep -rln 'workflow_run' .github/workflows/`. For each workflow using workflow_run,
   open the workflow it names and check whether it reads anything the triggering workflow
   produced (artifacts via actions/download-artifact, a ref or tag the first workflow created,
   an output it set). If it reads nothing across that boundary, propose merging the two
   workflows into one with separate parallel jobs instead of a workflow_run chain. If it
   does read something, leave it - it is a real dependency, not a serialization bug.
2. Run `grep -rn 'max-parallel' .github/workflows/`. For each match, read the matrix and
   the steps inside the job. Identify whether the steps share a resource that cannot be
   hit concurrently (single deploy target, rate-limited API key, one shared database).
   If you find no such resource, propose removing max-parallel or raising it. If you do
   find one, do not remove the throttle - instead propose isolating the shared-resource
   step into its own serialized job or a concurrency group scoped to that resource, so the
   rest of the matrix can still run in parallel.
3. Read the GitHub Actions docs on workflow_run, reusable workflows, and matrix strategy
   linked on this page before making changes.
4. Show the full diff and open a pull request; do not apply changes blindly. In the PR
   body, name each workflow or matrix changed, state what dependency check you did (what
   you confirmed was or was not read across the workflow_run boundary, or which shared
   resource you did or did not find), and state how to verify: compare the run's wall-clock
   time and job start times before and after.
```

Confirm the change landed:

1. For a merged workflow_run chain, push a commit and watch the Actions run: the jobs that used to be two separate workflow runs should now appear as parallel jobs inside one run, starting at the same time rather than one waiting on the other's completion event.
2. For a raised or removed max-parallel, open the run's Actions UI and confirm the matrix's variants show overlapping start times instead of a strict start-after-previous-finishes order.
3. Compare the total wall-clock time of the run before and after: a merged workflow should drop by roughly the amount that was previously spent waiting on the workflow_run completion event and re-dispatch, and a de-throttled matrix should drop toward the duration of its single slowest variant instead of the sum of all variants.

<a id="related-pages"></a>

## Related pages

- [Remove needs: dependencies that do not exist](https://starsling.dev/github-actions/optimizations/remove-unnecessary-job-dependencies)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)

<a id="sources"></a>

## Sources

- [GitHub Actions: events that trigger workflows (workflow_run)](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run)
- [GitHub Actions: reusing workflows (workflow_call)](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)
- [GitHub Actions: running variations in a job matrix](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations)
