Five tips for faster GitHub Actions

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.
- 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 ./...
The actions/setup-go@v5 action and its automatic caching save us 19s (down to 6s)!
- 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 ./...
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.
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.
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:
jobs:
test:
runs-on: ubuntu-latest # 4 vCPU, 16 GB RAM
steps:
- uses: actions/checkout@v4
- run: go test ./...becomes:
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:
jobs:
lint:
runs-on: nscloud-ubuntu-22.04-amd64-2x4
build:
runs-on: nscloud-ubuntu-22.04-amd64-16x32Running 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.
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:
- 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
With Namespace remote Builders, the same build takes 16 seconds:
- 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
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.


