Skip to content
hm::dev
← all posts

Cutting a GitHub Actions pipeline from 8 minutes to under 2

updated 2026-08-08 · by Hagop Minassian

A one-line change to a config file was taking eight minutes to go green. Not because the change was complex, because the pipeline ran everything from scratch every time. Here’s what got that down to under two minutes, and what I tried that didn’t move the number at all.

Where the eight minutes went

I timed every step on a normal PR run before touching anything. Checkout: 12 seconds. npm ci: 94 seconds, because there was no cache and the lockfile pulled in every dependency fresh. Playwright browser install: 61 seconds, same problem, no cache, downloading Chromium and Firefox binaries on every single run. Lint, typecheck, unit tests, and e2e tests ran one after another in a single job: another five minutes combined.

None of the individual steps looked wrong on their own. The problem was that the pipeline treated every run like a cold machine that had never seen this repo, and it ran everything in sequence when most of it had no reason to wait on the others.

Caching the right things

actions/cache with a key based on the lockfile hash cut npm ci from 94 seconds to 8 on a cache hit. That part is well documented and most people already do it.

The Playwright cache took more trial and error. Caching ~/.cache/ms-playwright keyed on the Playwright version in package.json got the browser install down from 61 seconds to 4. The catch: if you bump the Playwright version and forget the cache key still points at the old one, tests fail with a browser-not-found error that has nothing to do with your code. I lost twenty minutes to that the first time.

Splitting jobs instead of steps

The bigger win wasn’t caching, it was running lint, typecheck, unit tests, and e2e tests as four separate jobs instead of four steps in one job. Steps in a job run on the same runner, one after another, no matter what. Jobs run on separate runners, in parallel, by default.

Splitting them turned five minutes of sequential steps into ninety seconds of parallel jobs, bounded by whichever job took longest, which was e2e tests. Each job pulls its own cache, so the total runner-minutes billed went up slightly. Wall-clock time for a PR to go green dropped by more than the cost increase was worth arguing about.

What didn’t help

A self-hosted runner shaved another twenty seconds off startup time by skipping the queue wait, but it meant patching a machine and rotating its token, for twenty seconds. Not worth it for a team of three.

Bumping to a larger hosted runner (4 cores instead of 2) helped the e2e job by about fifteen seconds and cost more per minute. I kept it for that one job, not the whole pipeline.

Adding concurrency to cancel superseded runs on the same branch didn’t touch the per-run time, but it cut the number of runs in half on days with a lot of small pushes, which is a different kind of win.

The pipeline now

Checkout with fetch-depth: 1, cache restore, four parallel jobs, concurrency cancellation. A PR that used to sit at eight minutes now lands between 1:40 and 2:10 depending on cache hits. The lockfile-hash cache key means the first run after a dependency bump is still slow, that’s expected and fine, it’s the every-run cost that mattered.

The whole change was maybe forty lines of YAML. None of it was clever. It just stopped treating a cache-warm repo like a cold one.

Next
Why Laravel is still my default for a client's internal CRUD app