quickstart →

Parallel steps in GitHub Actions: background, wait and parallel

Run steps of one GitHub Actions job at the same time with parallel, background, wait and cancel, with a before/after setup that stops waiting in line.

Until 2026, the steps of a job ran strictly one after the other. If you wanted two things to happen at once, you split them into separate jobs (and paid for a second runner and a second setup), or you backgrounded a process with & in a shell script and lost its logs in the noise.

GitHub shipped parallel steps ↗ in June 2026, and the feature is now generally available ↗. Five new step keywords let a job do several things at the same time on the same runner, each with its own log:

KeywordWhat the step does
background: trueStarts the step and moves on to the next one without waiting.
wait: <id> or wait: [<id>, <id>]Blocks until the named background steps finish.
wait-all:Blocks until every running background step finishes.
cancel: <id>Stops a background step: the runner signals the step’s process (SIGINT, then SIGTERM) and kills it if it doesn’t exit.
parallel:Runs a group of steps at once and waits for all of them.

parallel is shorthand: each step in the group runs as a background step, with a wait at the end of the group. The full reference is in GitHub’s workflow syntax docs ↗.

Where parallel steps pay off: job setup#

Most CI jobs don’t spend their time in the tests. They spend it getting ready: starting a database, installing PHP or Python dependencies, installing Node dependencies, downloading browsers, building assets. Those steps rarely depend on each other, yet they queue up one behind the other.

Here is a typical end-to-end test job for a PHP web app with a JavaScript frontend and Playwright tests. Every step waits for the previous one:

.github/workflows/ci.yml
name: E2E
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Start MySQL
run: |
docker run -d --name mysql \
-p 3306:3306 \
-e MYSQL_ALLOW_EMPTY_PASSWORD=yes \
-e MYSQL_DATABASE=app_test \
--health-cmd="mysqladmin ping -h127.0.0.1" \
--health-interval=2s \
mysql:8.4
- name: Configure the app
run: cp .env.ci .env
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
- name: Wait for MySQL
run: |
timeout 120 bash -c \
'until [ "$(docker inspect -f {{.State.Health.Status}} mysql)" = healthy ]; do sleep 2; done'
- name: Migrate and seed the database
run: php artisan migrate --seed --force
- uses: pnpm/action-setup@v6 # reads the version from packageManager in package.json
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Build frontend assets
run: pnpm run build
- name: Run Playwright tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: playwright-report/

(Playwright’s webServer ↗ setting starts the app and waits for it before the tests run.)

Draw the dependencies and most of that list turns out to be independent:

  • MySQL only has to be healthy before the migrations and seed, which come near the end.
  • composer install has to finish before the migrations, and in most Laravel apps before the asset build too. Laravel’s React and Vue starter kits run php artisan wayfinder:generate when the Vite build starts, and the Livewire kit’s CSS imports a file from vendor/.
  • The Playwright browser download and the frontend build both need node_modules, but not each other.
  • Nothing on the JavaScript side needs the database until the tests start.

So the job really has two chains, PHP (database and Composer) and JavaScript (pnpm, browsers, build). The build waits for Composer, and both chains meet before the migrations.

After: the same job with parallel steps#

.github/workflows/ci.yml
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Configure the app
run: cp .env.ci .env
# PHP chain: runs in the background while the JS chain runs below.
- name: Start MySQL and wait until it is healthy
id: mysql
background: true
run: |
docker run -d --name mysql \
-p 3306:3306 \
-e MYSQL_ALLOW_EMPTY_PASSWORD=yes \
-e MYSQL_DATABASE=app_test \
--health-cmd="mysqladmin ping -h127.0.0.1" \
--health-interval=2s \
mysql:8.4
timeout 120 bash -c \
'until [ "$(docker inspect -f {{.State.Health.Status}} mysql)" = healthy ]; do sleep 2; done'
- name: Install PHP dependencies
id: composer
background: true
run: composer install --no-interaction --no-progress
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
# JS chain: runs in the foreground.
- uses: pnpm/action-setup@v6 # reads the version from packageManager in package.json
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
# The asset build runs php artisan (Wayfinder) or imports files from vendor/.
- name: Wait for Composer
wait: composer
- parallel:
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Build frontend assets
run: pnpm run build
# The two chains meet here.
- name: Wait for the database
wait: mysql
- name: Migrate and seed the database
run: php artisan migrate --seed --force
- name: Run Playwright tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: playwright-report/

What changed:

  • The database and Composer start right after checkout as background steps with an id, so the job can wait for them by name later.
  • The “Wait for MySQL” step moved inside the database step. The step now ends when MySQL is healthy, so wait: mysql means “the database is ready”, not just “the container started”.
  • The browser download and the asset build run side by side with parallel once Composer is done: both need node_modules, and the build also needs vendor/.
  • wait: composer and wait: mysql are the meeting points. If a background step failed, its wait step fails, and the job stops there with that step’s log.

If you start the database with services: ↗, it can’t overlap with your setup: the runner pulls and starts every service container before the first step, and waits for its health check if it has one. Starting it from a background step, as here, lets it boot while the dependencies install.

The wall-clock time of the setup becomes the longer of the two chains instead of their sum, as long as Composer finishes before pnpm install does. On a job where the database takes 30 seconds to become healthy, Composer a minute, and the JavaScript side two or three minutes, that is a minute or two saved on every run, with no extra runner and no extra checkout.

background or parallel?#

Use parallel for a self-contained group: steps that start together and must all finish before the job moves on, like the browser download and the build above.

Use background when you need more control:

  • A step that should keep running while later steps execute, like a server or a database.
  • A step you want to wait for later, by id, from somewhere else in the job.
  • Two chains that interleave, like the PHP and JavaScript chains above. Each entry of a parallel group is a single step, so a chain of dependent steps (pnpm, then Node, then install) stays in the foreground, or goes in a composite action that runs in the background.

Servers that never exit#

A step running a server in the foreground never finishes on its own. Start it in the background, check that it answers (wait: server would wait for it to exit, not to be ready), run what needs it, then stop it with cancel:

.github/workflows/ci.yml
steps:
- name: Start the app server
id: server
background: true
run: exec php artisan serve --port=8000
- name: Wait until the server answers
# Without -f, any HTTP answer counts, even a 404 at /.
run: timeout 60 bash -c 'until curl -s -o /dev/null http://localhost:8000; do sleep 1; done'
- name: Run Playwright tests
run: pnpm exec playwright test
env:
PLAYWRIGHT_TEST_BASE_URL: http://localhost:8000
- name: Stop the app server
cancel: server

Don’t leave it running: GitHub runs an implicit wait-all before post-job cleanup, and a server that never exits keeps that wait going until the job hits its timeout-minutes. The exec matters too: it makes the server the step’s process, so the stop signal reaches it. Without it, cancel ends only the shell, and the server keeps running until the job ends.

Starting the server this way replaces Playwright’s webServer ↗ setting. Remove that block from playwright.config (or set reuseExistingServer: true in it), or Playwright fails in CI with http://localhost:8000 is already used. PLAYWRIGHT_TEST_BASE_URL is the baseURL Playwright uses when the config doesn’t set one.

Rules and limits#

From GitHub’s workflow syntax reference ↗:

  • 10 at a time. At most 10 background steps run concurrently in a job, parallel groups included. Extra ones are queued until a slot frees up.
  • Outputs arrive at the wait. Outputs and environment changes ($GITHUB_ENV, $GITHUB_PATH) of a background step are only visible to later steps after a wait or wait-all that includes it. A step that needs steps.<id>.outputs has to come after the wait.
  • Failures surface at the wait. A failed background step fails the job at the next wait or wait-all that includes it, unless it has continue-on-error: true.
  • No if: on control steps. wait, wait-all and cancel steps always run and don’t accept if:. The background steps themselves can have one.
  • No composite actions. A composite action can’t declare background or parallel steps inside it, but a composite action can itself run as a background step.
  • run and uses both work. You can run actions such as actions/setup-node in the background, as long as the following steps don’t depend on them before a wait.
  • GitHub.com only. The reference documents the keywords for GitHub.com and GitHub Enterprise Cloud, not GitHub Enterprise Server.

Two practical caveats:

  • Steps share the runner. Parallel steps compete for the same CPUs, memory and disk. ubuntu-latest has 4 vCPUs and 16 GB of RAM in public repositories, but 2 vCPUs and 8 GB in private ones. Even on 2 vCPUs, overlap pays for steps that mostly wait on the network or disk, like pulling and starting MySQL or downloading packages and browsers. CPU-heavy steps, such as a production build, mostly slow each other down. Give the job enough cores for what runs concurrently, for example with a larger runner, or keep heavy work in separate jobs.
  • Tooling may lag. Editor extensions that use GitHub’s language service or SchemaStore accept the new keys once updated. As of November 2026, no actionlint release does. The latest, 1.7.12 (March 2026), reports background and the wait, wait-all, cancel and parallel steps as errors, and it doesn’t check the steps inside a parallel group. Filter those errors with actionlint -ignore 'unexpected key "background"' -ignore 'step must run script', or add the two patterns to an ignore: list under paths: in .github/actionlint.yaml, scoped to the affected files. The second pattern also hides a real step that has neither run nor uses.

Parallel steps or parallel jobs?#

Parallel jobs (see jobs and steps) give each piece its own machine: separate CPUs, independent retries, different OS or architecture. Each one also repeats the checkout, the dependency install and the cache restore, and bills its own runner minutes.

Parallel steps share one machine and one setup. They fit work that needs the same checkout and dependencies, and they are the right tool for job setup, which is the part jobs can’t share.

A good split for the example above: keep setup and the E2E run in one job with parallel steps, and if the test suite itself is long, shard it across jobs with a matrix.

On RunsOn#

RunsOn runners are built from the latest GitHub Actions runner release, so background, wait, wait-all, cancel and parallel work as they do on GitHub-hosted runners.

Since parallel steps share the runner, size it for what runs at once: on RunsOn Flex, runner=8cpu-linux-x64 in the job’s label gives the job above 8 vCPUs. Magic Cache is opt-in: add extras=s3-cache to the same label and run runs-on/action@v2 as the first step, and setup-node then caches the pnpm store in S3, in your AWS account:

.github/workflows/ci.yml
jobs:
e2e:
runs-on: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/extras=s3-cache
steps:
- uses: runs-on/action@v2
- uses: actions/checkout@v7
# ... same steps as above

On Fleet, the platform team sets the size and the s3-cache extra in the runner definition. EC2 bills instances by the second, so a bigger runner that finishes sooner often costs about the same as a small one that waits on itself. See rightsizing runners to pick a size.