Back to all blogs

How to Speed Up GitHub Actions

How to Speed Up GitHub Actions

Four things make GitHub Actions slow: dependencies downloading again on every run, jobs running one after another that could run at once, a cache that restores without saving any work, and a runner CPU about half the speed of a dedicated one. Most workflows have several. Speeding up GitHub Actions means finding which one is holding up the merge, then fixing that one first.

27 August 2026

Avrea handles the hardware and the cache. Install the GitHub App once, change one line in your workflow file, and both are done. Everything else here is free and works on any runner, ours included.

If most of the time goes to...Try first
npm/pnpm/pip/Cargo downloadsThe setup-* actions' cache: input, or a registry proxy in the same datacenter
Getting the repo onto the runnerShallow clone, sparse-checkout in a monorepo, or a local Git mirror
Docker buildsLayer ordering first, then an exported layer cache
CompilationA persistent build cache (Bazel, ccache, sccache, Turborepo...)
A long test suiteMatrix sharding
Work that stays CPU- or I/O-bound once the caches hitA faster runner
Old runs stacking up after every pushA concurrency group
Jobs that don't touch what changedpaths: filters

Why is GitHub Actions slow?

GitHub Actions is slow mostly because of the hardware underneath it. On runs-on.com's rolling CPU benchmark (August 19, 2026), GitHub's standard x64 runners score 2,200 to 2,678 PassMark single-thread depending on which host CPU your job lands on: AMD EPYC 7763 and 9V74, Intel Xeon Platinum 8370C and 8573C. You don't get to pick. Managed third-party runners score 4,200 to 4,700 on the same test.

On a private repository ubuntu-latest is 2 vCPUs, 8 GB of RAM and 14 GB of SSD storage; public repos get 4 and 16. GitHub sells larger tiers up to 96 cores, though only to organizations on Team or Enterprise Cloud, and more cores won't speed up a serial linker.

Avrea scores 4,453 on that benchmark, about 1.9x GitHub's standard runners. Namespace is 4,663 and Blacksmith 4,217. In July those three sat within 1% of each other and they now spread over about 10%, so CPU is only part of the difference. The rest is the cache and the network path to it.

1. Find the job that's blocking the merge

GitHub's run view gives you step-level durations. Start with which checks gate the merge: the required checks in your branch protection or ruleset, plus everything they pull in through needs:. Speeding up a check that gates nothing changes your invoice and not your wait.

Why speeding up your slowest job stops helping

Wall clock through a fan-out is the entry gate, plus the slowest job, plus whatever aggregates at the end. Every job beside that slowest one adds nothing to your wait and everything to your bill.

Cut it and you save time until it's no longer the slowest, at which point the second-slowest job sets the clock and you're paying for a fix nobody feels. That's why the work is a band of jobs at the top, not one job. It's also why runner-minute savings add up across fixes and wall-clock savings don't: two fixes aimed at the same job stop overlapping the moment something else becomes the constraint.

Which job is slowest changes between runs, so sample five before you name one. Check that earliest job start to latest job end matches the run's duration. If it doesn't, a needs: chain is serializing jobs you assumed ran together.

Split the slow job by step, then fix the biggest share

Split the gating job into steps and label each one: checkout, install, build, test, scan, package, setup. Whichever category dominates is the one to fix, and the table at the top says what to try.

A step whose mean sits well above its median is fine most days and awful on a few, so size it on the bad ones.

Durations tell you where the time went, not why it went there. Avrea records CPU, memory and network per step, so a step that doubled shows up as pegged CPU, memory pressure or a slow download, which is the difference between buying a bigger runner and fixing a cache. When that isn't enough, SSH into the job while it's still running:

avr jobs ssh <job-id>

You land in the working directory as the user the workflow runs as, with the toolchains, environment and caches in place, so you can re-run the failing command instead of adding an echo and pushing again. To catch a job that already failed, hold the VM open with a sleep step under if: failure().

2. Use faster GitHub Actions runners

Swapping in a faster runner is a one-line change to runs-on:. Faster hardware won't fix repeated work, serial jobs or a cache that isn't catching anything. For anything CPU- or I/O-bound, test it before you spend a week on the workflow file.

Hosted, self-hosted, or third-party runners

Self-hosted runners are machines you register with GitHub yourself: you own the image, the patching and the pager. GitHub ships a Kubernetes add-on for running them at scale, Actions Runner Controller (ARC). It starts one runner pod per job and grows or shrinks the pool with demand. This is the answer when the runner has to sit inside your own VPC. The longer version of this comparison, including what each option costs to operate, is in our guide to hosted, self-hosted and third-party GitHub Actions runners.

OptionWhat you operateWhat you controlCost shape
GitHub-hosted, standard or larger tiersNothingRunner size, 2 to 96 coresPer minute, billed by GitHub
Third-party providerNothing, one runs-on: labelRunner size and cache behaviorPer minute, billed by the provider
Self-hosted on Kubernetes (ARC)Cluster, autoscaler, image and patch cycleInstance type, VPC, region, data residencyYour cloud bill plus the engineering time

On Avrea:

# Before
runs-on: ubuntu-latest

# After
runs-on: avrea-ubuntu-latest

avrea-ubuntu-latest is 2 vCPU, the same core count you had. What changes is the silicon and the disk under it: dedicated AMD EPYC 4585PX at 5.7 GHz on Linux, Apple M5 Max for macOS and ARM. Sized labels (avrea-ubuntu-latest-8-vcpu) add cores for work that parallelizes. In our benchmarks, a project with no compatible remote cache (React, on Yarn classic) ran 2.1x faster end to end on hardware alone, the floor for a workload that can't cache. Every job also gets its own VM, so you aren't waiting on a shared pool: median pickup in the GitHits case study was 16 seconds.

3. GitHub Actions cache: what to cache, and why yours might not be hitting

The GitHub Actions cache (actions/cache) saves a directory under a key you choose and restores it on later runs of the same repository. Cache what gets rebuilt identically every run: package downloads, compiled artifacts, and the caches your own tools write.

Use the caching built into setup-node, setup-python, setup-java and setup-go before you hand-roll keys. On a runner that already proxies the registry locally, that cache: line buys less than it does on GitHub:

- uses: actions/setup-node@v7
  with:
    node-version: 22
    cache: npm   # buys less on a runner that proxies the registry locally
- run: npm ci

It finds the cache directory for your package manager and keys on the lockfile. actions/cache covers what the setup actions don't, though you wire that up yourself, and on an ephemeral runner anything you didn't persist is gone when the job ends.

Check where the cache gets written before you debug keys. GitHub scopes entries to the branch that created them, plus the default branch and, for a pull request, its base branch. If only your PR workflow writes the cache, every new PR off main starts cold by design. Save on pushes to your default branch too and PRs have something to inherit.

The cache: input stores the tarballs in ~/.npm, which is worth a lot when the alternative is pulling them across the public internet and worth less when the registry is a proxy one hop away. We haven't measured where the crossover sits, so time both on your own repo. On Avrea the proxy is on by default and pre-configured for npm, yarn, pnpm, bun, pip, Go modules, Cargo, Maven, Gradle, NuGet, Chocolatey, RubyGems and SwiftPM. uv is the exception: you export UV_INDEX_URL yourself.

Avrea runs four cache layers on the same hardware as the runner. The Actions cache proxy is drop-in compatible: in our testing, a 1 GB restore that averages 73 MB/s and about 14 seconds on GitHub runs at 354 MB/s and under 3 seconds through the colocated proxy. All four share one 25 GB quota per repository.

LayerWhat it holdsOn by default
GitHub Actions cacheWhatever actions/cache and the setup-* actions writeYes
Package cacheRegistry downloads for nine ecosystems, through a pull-through proxyYes
Build cacheCompiler and build-tool output for eleven tools: Bazel, ccache, Go, Gradle, Maven, Nix, Nx, sccache, Turborepo, Tuist, XcodeYes, except Tuist (macOS only, off by default)
Git LFS cacheLarge binaries pulled by actions/checkout with lfs: trueNo, switch it on per org or repo

The bigger multipliers belong to the build tool and not the transfer. ccache and Turborepo skip work they have already done, which is how a 27-minute Linux kernel build lands at 24 seconds warm and a Next.js and Turborepo build lands at 142x. GitHub has no equivalent layer. You can push a compiler cache through actions/cache by hand, but it crosses the network every run to get there.

actions/checkout: getting the repo onto the runner

Clone time costs about the same on every job, so no single run stands out. actions/checkout has defaulted to a shallow, single-commit clone since v2. A job that only posts a notification or calls an API doesn't need the repo at all. In a monorepo, sparse-checkout narrows the clone to the directories a job touches:

- uses: actions/checkout@v7
  with:
    sparse-checkout: |
      apps/web
      packages/ui

Avrea keeps a mirror of the repo in the same datacenter as the runners and preloads it before the job starts, so actions/checkout reads from the mirror and falls back to fetching from GitHub if the mirror isn't ready. Git checkout acceleration is off until you turn it on:

avr settings set cache.git.enabled true

Set it per organization or per repository. Submodules are a separate switch (cache.git-submodules.mode, with none, top-level and recursive), the first run after you enable it can still fetch from GitHub while the mirror syncs, and it covers Linux and macOS. Windows runners take the normal checkout path.

The caches your tools already write

Your linter, formatter and type checker each ship a work cache, and CI routinely leaves the flag off or points it somewhere the next VM will never see: prettier --cache --cache-strategy content, eslint --cache, tsc --incremental, jest --cacheDirectory=<persisted path>. These store the result of the work, so without them the tool re-parses every file even when the directory restored perfectly.

They also embed absolute paths and stat metadata that differ on a fresh VM, so a common outcome is "Cache restored successfully" followed by the tool scanning thousands of files anyway. Check the tool's own output, not the cache step's. Avrea's build cache covers the compiler and task-runner side of this, Turborepo and Nx tasks included, but not a standalone eslint --cache outside a task graph.

4. Cancel superseded runs with a GitHub Actions concurrency group

Every push after the first makes the run in progress pointless, and by default GitHub lets it finish. A concurrency group with cancel-in-progress kills the superseded run. Scope it to pull request events so a push to your default branch can never stop a deploy halfway through:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

That key uses the PR number on pull request runs and github.ref everywhere else, with github.workflow in front so two different workflows never cancel each other. Don't build it on github.head_ref: that's empty on push events, so every push collapses into one group. And cancel-in-progress: false queues the second run behind the first, so a collision there stalls a deploy.

A canceled run doesn't finish its post-job cache save, so it throws away whatever it warmed. That's a second reason to keep the default-branch run, usually the one seeding your caches, out of the canceling group. If you use a merge queue, be careful what you cancel: killing a required check on a merge_group run fails the merge. What this buys is runner minutes, unless the canceled run was holding capacity something else needed.

5. Shard tests with a GitHub Actions matrix strategy

A matrix runs the same job several times with different inputs, all at once. For a test suite the input is a shard number.

Check what a single job already uses first. Jest defaults to CPUs minus one worker, which is one worker on a 2-vCPU runner, and pytest runs serially until you pass -n auto. Whether you're saturating one machine (--maxWorkers=100%, pytest-xdist, cargo's --test-threads) also decides whether a bigger runner does anything for you.

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npm test -- --shard=${{ matrix.shard }}/4

Keep that denominator in sync with the shard list. strategy.job-total reads it for you, but it counts every job in the matrix, so it only works while shard is the single axis. Add an os: or node: dimension and the denominator becomes the product, shards 1 to 4 run as 1/8 through 4/8, and half the suite silently never runs.

Sharding cuts wall clock. It does not cut runner minutes: every shard pays checkout, toolchain setup, install and cache restore again, so the split floors out at that per-job setup tax. If going from four shards to eight saves almost nothing, you've hit it.

Rebalance numbered shards with timing-based splitting (pytest-split). Legs that aren't fungible, one package each or Postgres against MySQL, have to be split individually. fail-fast also defaults to true, so the first shard to fail cancels its siblings and you get one failure per run to work with.

If your shards start in two waves, you're hitting GitHub's per-account concurrency cap, and adding shards makes it worse.

6. Skip and scope work with paths filters and change detection

paths: filters on the pull_request or push trigger keep a docs-only change from triggering a full test matrix:

on:
  pull_request:
    paths:
      - "src/**"
      - "package.json"
      - "package-lock.json"

A trigger-level paths: filter stops the workflow running at all on a non-matching change, so a required check tied to it never reports and the PR sits blocked forever.

Filter inside the workflow. A change-detection job built on dorny/paths-filter runs every time, and everything downstream skips on its output while still reporting a status:

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
    steps:
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            frontend:
              - 'apps/web/**'
  build:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
  required:
    if: always()
    needs: [changes, build]
    steps:
      - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1

Make that last job the only required check in branch protection. Shard counts and conditional jobs can then change without anyone touching it again. Both the if: always() and the explicit failure test are load-bearing: without the second, it passes even when everything it needs failed.

Put the condition on the job, not the steps. A step-level if: still boots the VM before every step no-ops, six times over on a six-way matrix.

Skipping on draft PRs (if: github.event.pull_request.draft == false) is the same pattern with a hole in it. The default pull_request types are opened, synchronize and reopened. Flipping a draft to ready fires none of them, so the commit merges on the status it earned as a draft, and your expensive job never ran. Add ready_for_review to types: whenever you add that if:.

Then ask whether your slowest blocking checks need to be required at all. Run one on PR open rather than every push, which means later commits merge on the status the first earned, or move it to a post-merge tier that reports without blocking.

Only run what changed, and the four ways it breaks

Scoping the build and tests to the diff is the biggest lever here. Resolve the base commit from whichever event fired, and fall back to a full run when there isn't one:

env:
  BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
steps:
  - uses: actions/checkout@v7
    with:
      fetch-depth: 0
      filter: blob:none
  - run: |
      if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ] \
         || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
        echo "no usable base, building everything"
        turbo run build test
      else
        turbo run build test --filter="...[$BASE_SHA]"
      fi

nx affected -t build test --base=$BASE_SHA and vitest --changed $BASE_SHA take the same input. Reach for github.base_ref and your merge-queue and post-merge runs scope to nothing, because it's only set on pull_request and pull_request_target events.

Four things break, all of them quietly:

  • The dependency graph misses an edge: an undeclared dependency, a runtime import, a generated file.
  • Coverage drops and nobody notices.
  • You test source that was never built, while production ships dist/.
  • A scoper that resolves to "nothing affected" still exits 0, so a broken import reads as a green check.

Turbo and nx at least fail loudly on an unresolvable base. vitest --changed with passWithNoTests on exits green having run nothing.

The base commit also has to exist in the clone. fetch-depth: 0 with filter: blob:none gets you the commit graph without the file contents, and you pay that back on demand in later commands that need blobs. If your tooling only needs the one commit, git fetch --depth=1 origin $BASE_SHA on top of the default shallow checkout is cheaper. Run scoped and full side by side for a week of PR traffic before you cut over. A speedup that runs fewer tests is indistinguishable from a real speedup until a bug ships.

7. Speed up Docker builds in GitHub Actions

Docker layers that rebuild every time usually come down to build order. The layer cache invalidates from the first changed instruction onward, so a COPY . . above the dependency install rebuilds everything below it whenever any source file changes:

COPY package-lock.json package.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .

That cache mount helps inside a single build. It doesn't travel with cache-to, it lives in the builder's local state, so on an ephemeral runner it starts empty every run. The layer cache does survive, but only if you export it:

- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
  with:
    context: .
    cache-from: type=gha
    cache-to: type=gha,mode=max

mode=max keeps the intermediate stages of a multi-stage Dockerfile; the default keeps only the layers in the final image. scope=backend and scope=frontend give two Dockerfiles in one repo separate entries. On Avrea, add url_v2 to both lines so BuildKit talks to the colocated cache:

    cache-from: type=gha,url_v2=https://cache.avrea.com/
    cache-to: type=gha,url_v2=https://cache.avrea.com/,mode=max

That parameter isn't optional there. Without it, Linux amd64 builds fall back to the slow upstream path and Linux ARM builds fail. The Docker layer caching docs cover the rest.

Add .git to your .dockerignore. Git metadata changes on every commit, so a COPY . . that sweeps it in invalidates the layer even when no source file moved.

A sleep 10 waiting for Postgres costs ten seconds on every run. Give the services: block health options (--health-cmd pg_isready) and the runner holds your steps until the container answers.

8. Stop CI time from creeping back

CI time creeps back one commit at a time. Someone adds a dependency without pinning it. A setup step in three concurrent jobs gets more expensive in all three at once. Nothing surfaces until the build is slow again and git blame points nowhere useful.

When the same step runs in several jobs at once, make it cheap in every one. Consolidating it into a single upstream job the others wait on turns parallel work into a serial stage.

Watch the keys themselves. A github.sha or github.run_id in a cache key writes a fresh entry every run and, with no restore-keys prefix behind it, reads none of them. Anything in a cache key that doesn't change the output is a scheduled cache wipe.

Flake belongs on the same list. A first-pass failure rate of f inflates effective time to green by roughly 1/(1-f) on the runs it hits, so 15% is about 1.2x, and it never shows in a duration chart because failed runs get filtered out. Effective wait is worse than that ratio, because the re-run waits for someone to notice the failure. Avrea tracks it directly: flake rate counts jobs that failed where the same step passed on other runs, over a rolling 30 days, with p50 and p95 durations beside it.

Before you delete a step, read the file's history. A check that looks redundant is often backing a correctness gate or a privilege split. Avrea's Pipeline Optimization, part of the AI quality features currently in beta, watches how your workflows run and how they're configured, surfacing slow jobs, cache misses, oversized runners, Dockerfile inefficiencies and leaked secrets, with a proposed change for each. Each change runs on an Avrea runner first, and if it passes you get a pull request to review. Nothing lands on main without a human.

How much can you speed up GitHub Actions?

There's no single multiplier: 2.1x from hardware alone on a project that can't cache, 142x on one that can. Each change moves one of those numbers or the other:

ChangeWhat it cutsWhat it depends onEffort
Faster runner hardwareBothWhether what's left is CPU/IO-boundOne label change (self-hosting is real infra work)
Persistent build and dependency cachingBothHow cacheable the workload isFree to configure, automatic on Avrea
Canceling superseded runsRunner minutesHow often a second push lands mid-runFree
Skipping and scoping unchanged workBoth, if the job was on the critical pathHow much of your CI is redundantFree, scoping needs a rollout
Matrix shardingDeveloper wait, at the cost of runner minutesHow evenly the suite splitsFree, some refactoring

Re-measure after each one. Start with a faster runner, since it's the cheapest thing to test: duplicate your slowest workflow file, point the copy at a different runner, and run it twice. The first run shows the hardware difference, the second shows the cache. Sign up at console.avrea.com and that test costs nothing: 3,000 minutes a month on 2 vCPU runners are included, and larger sizes draw that down faster. If it's still slow after all of this, tell us what it's running: hello@avrea.com.

Frequently asked questions

Why are my GitHub Actions builds so slow? Usually four things at once: dependencies downloading again every run, jobs running serially that could run in parallel, a cache that restores but doesn't stop the work, and a runner CPU about half the speed of a dedicated one. Find the job that gates the merge, split it by step category, and fix the biggest share first. A faster runner and a colocated cache are the two fixes that need no workflow rewrite.

How is Avrea's cache different from GitHub's actions/cache? Avrea runs four cache layers on the same hardware as the runner; GitHub gives you one general-purpose cache that you point at a directory and key yourself. Three of Avrea's are on by default: a drop-in version of the GitHub Actions cache, a package registry proxy for nine ecosystems, and a build cache for eleven compiled-artifact tools. The Git LFS cache and the datacenter-local Git mirror are switches you flip per org or repo.

How do I speed up GitHub Actions without changing runners? Add a concurrency group scoped to PR events, cache dependency installs with the setup-* actions' cache: input, turn on your linter and type checker's own cache flags, shard tests across a matrix, and filter unrelated jobs with a change-detection job. All of it works on GitHub-hosted runners, and switching later is one label change.

Do third-party runners use up my GitHub Actions free minutes? No. GitHub bills minutes for its own hosted runners, so jobs on a third-party or self-hosted runner don't draw down your included allowance. You pay that provider instead, and Avrea includes 3,000 minutes a month on 2 vCPU runners.