Five tips for faster GitHub Actions

banner
Giulio MicheloniGiulio Micheloni
8 MIN READ
Our engineers spent years writing CI/CD workflows at big software companies. Now we’d love to share tips that speed up GitHub Actions.
Get started — it takes less than a minute.

Our team has built several GitHub Actions workflows over the last year, and also many Continuous Integration (CI) pipelines at big software companies for many years. In this guide, we’ve collected five practical tips that can help you speed up your CI today.

In this fast-paced technology market, fast delivery is essential to stay competitive. To achieve this, software teams need to build and iterate features rapidly.

Slow GitHub Actions can negatively impact developers' productivity. Typically, developers consider their task as “done” only when the CI completes successfully. Waiting for long workflows to complete leads to frequent context switches back and forth between new and current tasks. This wastes developers’ time. Companies prioritizing fast CI can ensure their developers remain productive and focused, delivering high-quality features.

Causes for slow GitHub Actions

There are many reasons why GitHub Actions workflows are slow. Here is a list of the most common causes:

  • Repeated work: every workflow needs tools, software dependencies, and container images to complete successfully. Downloading and installing these dependencies can take time.
  • Workflow design: workflow steps can run unnecessarily, or their execution order can be inefficient.
  • Sequential execution: workflow steps run sequentially within a single job.
  • Runner Machines: workflow uses Runner instances with the wrong architecture (e.g. arm64 or amd64) or lacking compute resources (e.g. CPU and RAM).

Speed Up Strategies for GitHub Actions

Here we list the most effective strategies to improve GitHub Action speed.

Design Workflows for Speed

It is tempting to design a single Workflow that takes care of everything: launching static code analyzers, building the whole application, running unit and integration tests, and finally releasing every CI artifact. Every commit into the repo triggers this gigantic pipeline, which might take a while to complete.

Try instead to split your CI into multiple single-purpose workflows. Use path filters to trigger the workflows only when certain parts of the code are changed. For example, building and testing the frontend code should be a separate workflow from the backend code. The same concept can go even further; for example, your backend might be written in Rust and Go. You can have two workflows, one for Rust and one for Go, that run only when the respective source code is modified.

Thorough integration testing is mandatory for a high-quality product, but running it on each commit is expensive and unnecessary. You can leave a subset of integration tests that are fast to complete in the per-commit workflow and move the rest into a separate workflow. This new workflow can then use schedule to run periodically (e.g. every night). This approach gives the team enough confidence that their commits do not break the product without waiting for the entire test suite.

Order of steps matters. Run first the steps that are fast to complete but might fail often. For example, code formatters and linters are good candidates: they are usually quick, and you want to know that your code is not formatted correctly before waiting more than 10 minutes for the whole workflow to run.

Don’t repeat yourself - Use Cache

Don’t waste time downloading the same set of dependencies over and over. Intermediate results of previous Workflow job runs can be reused in new runs.

Consider using pre-baked GitHub Actions that take care of caching for you, such as action/cache or whywaita/actions-cache-s3. Storage space is not free, so be careful what you store in the cache.

GitHub provides language-specific actions that automatically set up language toolchains and perform caching on language artifacts. For example, if you are a Gopher, you can use actions/setup-go@v5 to install Go and cache modules.

For a simple application like our guestbook-go installing Go from official website and run go test can take up to 25s.

Code
- name: Install Go
  run: |
      curl -OL https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
      sudo rm -rf /usr/local/go
      sudo tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
      export PATH=$PATH:/usr/local/go/bin
      go version

- name: Check Go Formatting
  run: go fmt ./... && git diff --exit-code

- name: Check Go Mod Tidyness
  run: go mod tidy && git diff --exit-code

- name: Test
  run: go test ./...
go install without cache

The actions/setup-go@v5 action and its automatic caching save us 19s (down to 6s)!

Code
- uses: actions/setup-go@v5
  with:
    go-version: ">=1.20.0"

- name: Check Go Formatting
  run: go fmt ./... && git diff --exit-code

- name: Check Go Mod Tidyness
  run: go mod tidy && git diff --exit-code

- name: Test
  run: go test ./...
go install with cache

Take Advantage of Parallelism

Jobs that are independent of one another can run in parallel. Steps within a job can be moved into a new job and run in parallel. There are cases, however, where one job depends on the results of other jobs. Use needs to specify a sequential dependency between two jobs.

Maintaining the configuration of many parallel jobs can become problematic very quickly. Use matrix to write fewer job templates and configure them to run in parallel.

To limit the maximum number of parallel jobs, use max-parallel. This is especially useful if you have a private repository that consumes runner minutes from GitHub. Each minute costs you money, and you might want to protect yourself from large bills.

When using matrix, make sure that fail-fast is not configured to false (it’s true by default). This configuration will stop in-progress and queued jobs if one job belonging to the same matrix fails. This is especially important if you are paying for GitHub runner minutes. The matrix size can become bigger over time and failing jobs can waste your money.

Note, the fail-fast mechanism only applies to the matrix definition. So, two jobs listed separately like the following (i.e. jobA and jobB) will not stop each other in case the other fails.

Code
jobs:
  jobA:
    runs-on: [ubuntu-latest]
    steps:
      - ...

  jobB:
    runs-on: [ubuntu-latest]
    steps:
      - ...

In this case, you can use the andymckay/cancel-action@0.3 action to cancel the entire workflow run when the failure condition applies.

Code
jobs:
  jobA:
    runs-on: [ubuntu-latest]
    steps:
      - ...

      - uses: vishnudxb/cancel-workflow@v1.2
        with:
          repo: octocat/hello-world
          workflow_id: ${{ github.run_id }}
          access_token: ${{ github.token }}
        if: failure()

  jobB:
    runs-on: [ubuntu-latest]
    steps:
      - ...

      - uses: vishnudxb/cancel-workflow@v1.2
        with:
          repo: octocat/hello-world
          workflow_id: ${{ github.run_id }}
          access_token: ${{ github.token }}
        if: failure()

One final note on cost management. Be aware that parallelism can really speed up the whole GitHub Workflow, but that does not necessarily mean you are saving money. On the contrary, every job minute is counted independently. If, for example, the whole workflow takes 1 minute to complete, but it runs 5 parallel jobs of 1 minute each, then the final bill will be for 5 minutes, not 1!

Run on beefier Runners

The amount of compute resources on the runner can easily become the bottleneck for resource-intensive tasks, such as compiler, Docker builds, and tests. You can use GitHub Actions, such as runforesight/workflow-telemetry-action to track the resources utilization during a job run. If CPU or memory load is often 100%, your job might benefit from a bigger runner machine.

GitHub default runners have 2 vCPU and 7 GB of RAM. Larger runners are available to customers under the paid GitHub Team and GitHub Enterprise plans.

An attractive alternative is to deploy self-hosted runners in your own infrastructure and manage them. We’ve talked about the pros and cons of this approach here.

We see externally managed runners as striking a good balance between being simple to use, and offering the best price/performance ratio. This frees you and your team precious time to focus on your core business.

Offload Special Tasks

Certain types of special tasks benefit from specialized environments designed to run those tasks efficiently. A perfect example is Docker builds. By nature, Docker builds take advantage of faster CPU clocks, persistent caching, and CPU architecture for multi-platform builds.

Running Docker builds in GitHub Actions usually takes time, especially for multi-platform builds, as it needs to rely on QEMU hardware virtualization.

Another example of a special task that can benefit from offloading is Kubernetes. Teams often use Kubernetes to manage their applications in production. When it comes to CI, setting up a Kubernetes cluster and running tests against it in a GitHub Action is possible but typically slow.

How Namespace can help

At Namespace we provide high-performance hardware and tooling built for making CI workloads faster. We offer drop-in replacements for runners, caching, and Docker builds.

1. Switch to Namespace Runners

The highest-leverage change is running on better hardware. Namespace's default runner shape is 8 vCPU and 16 GB of RAM, twice the CPU of GitHub's standard hosted runners, and profiles scale up to 32 vCPUs and 512 GB of RAM for heavier workloads.

Switching is a one-line change to the runs-on field, nothing else in the workflow needs to move:

Code
jobs:
  test:
    runs-on: ubuntu-latest   # 4 vCPU, 16 GB RAM
    steps:
      - uses: actions/checkout@v4
      - run: go test ./...

becomes:

Code
jobs:
  test:
    runs-on: namespace-profile-default  # 8 vCPU, 16 GB RAM
    steps:
      - uses: actions/checkout@v4
      - run: go test ./...

You can also right-size each job by picking a shape directly in the runner label, so a lint job stays small while a build or test job gets more CPU or RAM:

Code
jobs:
  lint:
    runs-on: nscloud-ubuntu-22.04-amd64-2x4

  build:
    runs-on: nscloud-ubuntu-22.04-amd64-16x32

Running your first workflow on Namespace takes less than a minute. Give it a try.

2. Add Namespace Cache Volumes

Once you're on Namespace runners, caching is the next lever. GitHub's actions/cache uploads and downloads a tarball at the start and end of every job, the exact round trip we called out earlier as a source of wasted time. Namespace Cache Volumes are NVMe-backed storage mounted directly on the runner, so there is no upload or download step: the cache is already there.

Code
jobs:
  test:
    runs-on: namespace-profile-default
    steps:
      - uses: actions/checkout@v4

      - name: Setup Go cache
        uses: namespacelabs/nscloud-cache-action@v1
        with:
          cache: go

      - uses: actions/setup-go@v5
        with:
          go-version: ">=1.20.0"
          cache: false  # Namespace handles this instead

      - run: go test ./...

nscloud-cache-action has built-in support for common ecosystems like Go, Node, Rust, and Python, plus a path option for anything else you need cached. See our caching docs for the full setup.

3. Offload Docker Builds

We've talked before about how offloading special tasks to optimized machines can make GitHub Actions faster, and Docker builds are the clearest example. With Namespace, offloading a build to high-performance machines only requires a one-line change to your workflow, using the nscloud-setup-buildx-action.

For a simple application like our guestbook-go building a Docker image for linux/arm64 and linux/amd64 can take up to 3 minutes on a standard runner:

Code
- name: Set up QEMU
  uses: docker/setup-qemu-action@v2

# Setup build driver to use Buildkit container
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v2

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
    platforms: linux/amd64,linux/arm64
local docker build

With Namespace remote Builders, the same build takes 16 seconds:

Code
- name: Configure access to Namespace
  uses: namespacelabs/nscloud-setup@v0

# Use Namespace Builders
- name: Configure buildx
  uses: namespacelabs/nscloud-setup-buildx-action@v0

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
    platforms: linux/amd64,linux/arm64
nscloud docker build

If you also need to run CI tests against a production-like Kubernetes cluster, Namespace gives you test clusters outside of the resource-constrained runner. Call the nscloud-cluster-action action to get a cluster in less than 10 seconds.

For a deeper walkthrough of runners, cached checkouts, and Cache Volumes, read our guide How to speed up Github Actions runners.

Conclusion

In summary, consider these tips to speed up your GitHub Actions workflow.

  • Redesign the workflow: split it into smaller workflows, trigger them only when necessary, and move fast but more likely to fail tests first.
  • Cache: use cache providers to store dependencies and artifacts.
  • Parallelism: split the workflow into independent jobs and run them in parallel.
  • Use more powerful runners: default GitHub runners can be the bottleneck of your CI. Consider using bigger runners from GitHub or external providers.
  • Offload special tasks: certain tasks, such as Docker build and Kubernetes, can run slowly in default GitHub runners. Consider leveraging an external provider for specialized environments to run these tasks.

Frequently asked questions

Redesign your workflows so unrelated jobs run in single-purpose pipelines, cache dependencies instead of downloading them on every run, run independent jobs in parallel, and move resource-heavy steps like Docker builds onto larger or more specialized machines. Most slow pipelines are slow because they combine several of these problems at once, so it is worth measuring where time actually goes before optimizing.

Namespace addresses all of these at the runner level: bigger default machines, built-in caching, and dedicated builders, without requiring you to redesign your workflows first. Connecting a repository takes a one-line change to your workflow file, as described in our GitHub Actions guide.

It depends on what you are caching, but the savings compound with every run. In our tests, switching a Go install step from a manual download to actions/setup-go@v5 with caching enabled cut the step from 25 seconds down to 6 seconds, and that gap only grows as module and dependency trees get larger.

Namespace runners include NVMe-backed Cache Volumes that avoid the upload and download round trip GitHub's own cache relies on, so cache hits are close to instant once a workflow has run a few times. See our caching docs for how Cache Volumes compare to GitHub Actions Cache and Namespace Artifacts.

Not on its own. GitHub bills each job minute independently, so a workflow that finishes in 1 minute by running 5 parallel jobs of 1 minute each is still billed for 5 minutes, not 1. Parallelism reduces how long you wait for CI to finish, but it does not reduce the total compute you are paying for, and a misconfigured matrix can quietly multiply your bill.

With Namespace, faster individual runners mean each parallel job finishes sooner, so you often need less parallelism to hit the same wall-clock target in the first place. Our pricing page and billing and limits docs lay out exactly how job minutes are metered so you can budget for it.

Namespace's default GitHub Actions runner is 8 vCPU and 16 GB of RAM, compared to the 4 vCPU and 16 GB of RAM on GitHub's default hosted runners, and profiles scale up to 32 vCPUs and 512 GB of RAM for larger workloads. You choose the shape per job, so a memory-hungry test suite can get more RAM without changing every other workflow.

On top of the larger machines, Namespace adds NVMe Cache Volumes and dedicated remote builders for Docker, all through a one-line change to your existing workflow file. Start with our GitHub Actions guide or see the available shapes in our machine shapes reference.

Docker builds are a common CI bottleneck because they are CPU and I/O heavy, and multi-platform builds usually rely on QEMU emulation, which is slow. Offloading the build to a machine built for it, rather than the same runner handling the rest of your job, is generally the biggest lever you can pull.

Namespace Remote Builders are enabled by default on Namespace runners, so docker/build-push-action runs against dedicated build infrastructure with no docker/setup-qemu-action or docker/setup-buildx-action step required. Read our Docker builds guide or the broader Docker Builders docs for setup details.

Accelerate your developer team

Join hundreds of teams using Namespace to build faster, test more efficiently, and ship with confidence.