How to make GitHub Actions faster: seven levers, in the order to pull them
Almost every guide to a slow pipeline starts with a faster machine. It is the last lever worth pulling, not the first. A pipeline is slow for one of four reasons: it runs more often than it needs to, it waits for a machine before it starts, it repeats work it has already done, or the work it does is laid out in one long line. Each has a different fix, and the fixes cost very different amounts. This guide goes through seven of them in the order we would pull them, with the YAML, and with a link to the longer post for each one. It makes no promise of a multiple. Your pipeline is not ours, and the only honest number is the one you measure.
Measure first, and measure the right clock
Before you change anything, split the time a job takes into the part spent waiting and the part spent working. They look identical on a timeline and have nothing in common. A long wait is capacity, a concurrency limit or a label that matches no runner. A long run is your workflow. On our runners a job’s page shows the two apart, labelled, and its Resources tab plots memory, disk, CPU and network over the run, which is the evidence you need for the last lever below. On any provider, the step durations in the log do most of the same job: write down the five slowest steps of your slowest workflow before you touch it.
Then pick the number you are trying to move. Developers feel the time from push to a green check. Finance feels the sum of billed minutes. They are not the same number, and several fixes below move one and not the other. We say which as we go.
Lever 1: run less
The cheapest job is the one that never starts. Two runs of the same code for every push, a run that carries on after a newer commit has made it pointless, and a documentation change that triggers the full test suite are all pure waste, and removing them speeds up everyone else’s jobs as well as yours, because it shortens the queue. It lowers both numbers.
The longer guide, Speed up GitHub Actions by running less, has the details and the working files. The short version is three settings. Restrict the push trigger to the default branch so a pull request is not tested twice. Put a concurrency group on the workflow, with cancel-in-progress on pull requests only, so a superseded run stops. And give every job a timeout-minutes so a hung step cannot hold a machine for hours.
One caution belongs here and not later. Path filters are the obvious next step and they are the one place this lever can hurt: a workflow skipped by a path filter reports nothing, so if that workflow is a required check the pull request waits for ever. The run-less guide shows the pattern that avoids it.
Lever 2: stop waiting for a machine
If a job waits two minutes for a runner and then runs for three, the biggest saving available is not in the three. A wait has several common causes: a runs-on label that matches no runner, a runner that is offline, a concurrency cap, a runner group that does not include the repository, and a concurrency group holding the run. The post Waiting for a runner to pick up this job takes each in turn, with the fix.
Two things about waiting are worth knowing before you spend anything on it. First, on our runners a job that waits is not billed: time is counted from when your work starts running, so a queue costs you time and not money (the billing page says so). Second, how many jobs may run at once is a number you can read and change. The limits page has the plan’s figure and how to ask for more. If your workflow fans out into a matrix of thirty jobs and your cap is eight, the matrix, and not the machine, sets the wall-clock time. That is a good reason to read the number before you shard anything (lever 6).
Lever 3: cache what you download, not what you build
Most installs are downloads, and a download is the easiest work to skip. Every setup action for a language, setup-node for one, has a cache input. Read what it stores. For setup-node the README says it caches the package manager’s global data, keyed on the hash of the lockfile, and that it does not cache node_modules. So a cache hit saves the download, and the install step still runs. That is the right trade for most projects, and a surprise to anyone who expected the install to vanish.
The things that go wrong with caches are all in the key. A key that includes a timestamp never hits. A key that ignores the lockfile hits stale. A cache written by every branch fills the repository’s allowance and evicts the one that mattered. Two posts cover this end to end: GitHub Actions cache limit: 10 GB, 7-day eviction, scoping and keys for the rules GitHub applies, and How the Actions cache works on our runners for what changes on ours.
On our runners actions/cache works without a change to the workflow, and the cache is included in the plan: it is not metered and is not a line on the bill. The caching page explains how a job reaches it. If you were rationing cache keys to stay under a limit, check whether you still need to.
Lever 4: check out less
actions/checkout fetches a single commit by default: the fetch-depth input defaults to 1, and 0 means all history for all branches and tags. The default is already the cheap one. The trouble starts when someone set fetch-depth: 0 for a version-stamping script or a changelog tool and never set it back, so every job in the workflow downloads the whole history of a large repository to build one file. Find the one job that needs history, give it history, and leave the rest on the default.
Two further inputs cut the checkout down. sparse-checkout takes patterns, one per line, and writes only those paths to the workspace, which suits a monorepo job that builds one package. And filter asks for a partial clone, for example blob:none, which defers file contents until they are needed. The action’s own description is that a filter overrides sparse-checkout when both are set, so choose one.
A job on our runners starts on a fresh machine, so the checkout fetches from GitHub every time. If the repository is big and the job needs its history, runners-io/checkout is actions/checkout with one addition: when the job has a sticky disk it keeps a mirror of the repository there and fetches only what the repository gained since the last job. The checkout cache page is plain about the limits. It saves the download, so it helps most at fetch-depth: 0 on a large repository, it saves a couple of seconds at the default depth, and it needs sticky disks switched on for your organisation. Everywhere it cannot use the mirror it runs the ordinary checkout.
Lever 5: build Docker images with the layers in mind
If your pipeline builds an image, that build is probably its longest step, and it is where a cache pays most. The order of the Dockerfile decides what can be reused: the dependency manifests come first, then the install, and the source last, so an edit to source invalidates one layer and not all of them. Only then does the choice of cache backend matter, and that choice has real traps: the backends differ in where they store layers, in whether they export every intermediate layer, and in how they count against the repository’s cache allowance. Docker build cache in GitHub Actions compares them side by side and shows the multi-architecture build that runs each platform on its own machine instead of emulating one.
On our runners there is a layer cache of our own, priced by what it stores at $0.50/GB-month, in addition to the Actions cache that comes with the plan. The Docker builds page and the layer cache documentation say what is kept and how it is reused.
Lever 6: split the work, and count the minutes
Once a job is as lean as it gets, the remaining way to shorten the wait for a result is to run parts of it at the same time. There are three shapes. A matrix runs the same job over several inputs, such as operating systems or test shards. Separate jobs joined with needs let lint, unit tests and a build run side by side and gate the deploy on all of them. And GitHub now has parallel steps inside one job, which the run-less guide covers.
Sharding has a cost that is easy to forget: every shard repeats the checkout and the install. Four shards of a ten-minute test run are not four times cheaper. They finish sooner and they bill for more. Where a provider rounds each job up to a whole minute, a shard that ends after forty seconds is billed for sixty. So choose the shard count by adding up the billed minutes across the legs, not by the wall-clock time of the slowest, and stop adding shards when the sum starts to climb. Our runners bill from when the work starts to when it stops, and the pricing page has the rate per size, so the sum is arithmetic you can do. The cost calculator does it for a month of builds, at the billed rates, beside a GitHub-hosted baseline.
Splitting is also how you stay inside a job’s time limit. On our runners a job may run for 60 minutes, which is below the six hours some providers allow, so a build that takes longer should be cut into jobs that each finish inside it, with needs and artifacts carrying the work between them. The limits page has the rule.
Lever 7: right-size the machine, last
Now, and only now, the machine. A bigger runner helps a job that can use the cores: a compiler that parallelises, a test runner with workers, a bundler. It does nothing for a job that is waiting on the network or on a single-threaded step, and a larger size costs more for every minute, in proportion to the machine. The saving has to come from the minutes falling by more than the rate rises. That is a measurement and not a belief, which is why the first section of this guide was about measuring.
The two readings that decide it are CPU and memory over the run. If CPU sits near its ceiling for the slow step, a larger size should help. If it sits near one core, it will not. If memory climbs to the limit and the job slows or dies, size up for memory, and expect a faster run as a side effect. The full method, with the sizes, memory and disk of every label, is in GitHub Actions runner sizes: which size for which job, and the labels page is the reference.
A worked example, with made-up numbers
Here is how the levers add up on an invented pipeline, so you can see the shape of the arithmetic. None of these figures is a measurement of a real workflow, and yours will differ. A pull request runs lint, unit tests and an image build, one after the other, in one job, and the whole job takes twenty-six minutes. Every push to a branch with an open pull request starts it twice, and nobody cancels the older run.
Lever 1 removes the duplicate run and cancels the superseded one, which does nothing for the twenty-six minutes but halves the number of runs started and shortens everyone else’s queue. Lever 3 stops the install downloading the same packages on every run, and takes, say, two minutes off. Lever 4 finds a fetch-depth: 0 nobody needed and takes another minute. Lever 5 reorders the Dockerfile so the dependency layer is reused, and the image build drops from nine minutes to three. The job is now fifteen minutes. Lever 6 splits lint, tests and the image build into three jobs that run side by side, and the wait for a result becomes the longest of the three, perhaps six minutes. Total billed minutes have gone down as well, because the work that was repeated has gone, although the three jobs each repeat a checkout.
Only now is the size worth looking at, and the Resources tab says what you would have guessed: the test job is CPU-bound and parallel, so it gets a larger label, while lint and the deploy step go down to the smallest one. Look at what the order did. The first five levers cost nothing to run, and the last one is the only one that raises the rate.
Four mistakes that undo the gains
Changing several things at once. If you add a cache, shard the tests and move to a larger size in one pull request and the run gets faster, you have learned nothing you can reuse, and if it gets slower you cannot tell why. Change one thing and read the clock. Reading one run. A first run on a cold cache and a tenth run on a warm one differ for reasons that have nothing to do with the change, so compare several runs of each, and note which were cold. Optimising the average. A pipeline’s time is its slowest path, so shortening a job that is not on it changes nothing you can see, and it can raise your bill. Forgetting the cap. A matrix that creates thirty jobs against a limit of eight does not run thirty at once, and the wall-clock time then depends on the limit and not on the size of the machine. Read the limit before you read the chart.
If you only have an afternoon
Open your slowest workflow and do four things. Look for a duplicate trigger and a missing concurrency group. Look for a fetch-depth: 0 on a job that does not read history. Look for an install step with no cache behind it. Look at which jobs wait for each other with needs and whether they have to. Together they are one pull request, they change nothing about what your pipeline tests, and they cost nothing but the time to make them.
Where a faster runner does belong
Some work is simply compute-bound and already as parallel as it will get. For that, a bigger or different machine is the right answer, and we would rather you reach it by elimination than by default. Two honest notes. We do not publish a speed multiple in this guide, because a multiple measured on one project says little about yours; the way to know is to run your own workflow and read the Ran for number. And moving a workflow is cheap to try: the migration page opens the pull request that changes the labels, and its own checks run on our runners, so you see the result before you merge.
The order, on one page
Measure the wait and the run apart. Remove the duplicate and superseded runs. Read why a job waits and fix the label or the cap. Cache the downloads and check the key. Give each job only the history it needs. Order the Dockerfile and choose the cache backend. Split what can run side by side and add the billed minutes. Then resize the machine against the CPU and memory you measured. Each step is cheaper than the next, and each one makes the next one’s measurement cleaner. If you only have an afternoon, do the first two: they cost nothing and they are the ones most pipelines have never done.