Guides

Five tips for faster GitHub Actions

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.

    - 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)!

      - 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.

  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

We faced these very problems at Namespace and worked hard to find solutions. Here is where Namespace can help you.

Namespace Runners

You can get more powerful runners with Namespace. Our default runner shape is 8 vCPU and 16 GB of RAM. We found that beefier runners speed up many CI with a simple one-line change in your workflow file.

You also get more more control over parallelism with finer cost management. You can decide the shape of the runners you want to use for a given job. You know that your CI is memory hungry? No problem, specify more RAM in the runner label.

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

Specialized Compute

We've talked before about how offloading special tasks to optimized machines can make GitHub Actions faster.

With Namespace, offloading Docker builds to high-performant machines only requires a one-line change in your GitHub workflow file. You can use the nscloud-setup-buildx-action action to offload any Docker build to Namespace.

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

      - 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

However, with Namespace remote Builders we saved more than 2 minutes (down to 16s)!

      - 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 want to run CI tests against a production-like Kubernetes cluster, Namespace gives you test clusters outside of the resource-constrained runner. Just call nscloud-cluster-action action to get a cluster in less than 10 seconds.

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.