DEV Community

Cover image for My CI Took 13 Minutes. Now It Takes 140 Seconds.
Phil Rentier Digital
Phil Rentier Digital

Posted on Originally published at rentierdigital.xyz

My CI Took 13 Minutes. Now It Takes 140 Seconds.

You can get a faster CI and ship more often once you know which step eats the time.
1 command finds that step, and a cache on the right folder removes it.
On my blog, that took a full run from 768 seconds to 140, and Astro, the framework doing the build, had nothing to do with it.

If your pipeline sits above 10 minutes and you couldn't say which step eats them, you're where I was on 2 repos this week.
So where did those 13 minutes actually go, and what does it cost to get them back?
The first answer took 1 command.
The second one shows up on the bill.

Skip the Cache That Saves Nothing

The first repo was my store site, an Astro build with a lot of product images.
Runs took between 457 and 698 seconds, all in a single job.
The framework build was the obvious suspect, so I cached it.

The cache archive weighed 3.8 MB, and the run took the same time as before.

That 3.8 MB cache is a monument to the wrong step.

Then I looked at what it was supposed to save.
The Astro build itself took 2 to 4 seconds.
I had put a cache in front of the cheapest step in the whole pipeline (in my defense, the framework is the only part of a CI run with a famous name, so it gets blamed first).

So the framework accounted for a few seconds, and more than 450 seconds were going somewhere the cache never touched.

Find Your Slow Step in 1 Command

Step timings of a CI run: the image encoding step takes 618 seconds while the framework build takes a few
The Image Oven Behind Thirteen-Minute CI Runs

A CI run is a sum of steps, and each step leaves timestamps behind.
GitHub's REST API returns them for every job of a run: the endpoint GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs lists each step with its started_at and completed_at.
Subtract one from the other and you get what each step really cost, with no guessing involved.

The gh CLI makes the call and jq does the subtraction.
The first command gives you the IDs of your latest runs on main:

gh run list -R OWNER/REPO --branch main --limit 3 --json databaseId,conclusion,createdAt
Enter fullscreen mode Exit fullscreen mode

The second one prints every step of a run, sorted from slowest to fastest:

gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobs --jq '[.jobs[] | .name as $j | .steps[] | select(.started_at != null) | [$j, .name, (((.completed_at // .started_at)|fromdateiso8601) - (.started_at|fromdateiso8601))]] | sort_by(-.[2]) | .[] | @tsv'
Enter fullscreen mode Exit fullscreen mode

The // .started_at part keeps the math from breaking on a step that was cancelled and never got a completed_at.
The output is one line per step: job name, step name, seconds.
These are the top lines from my blog's run today, after the fix, with the job column removed and the repo names shortened:

Build   102
Check out repo  24
Check out helper repo   2
Check TypeScript    2
Cache AVIF conversion   2
Set up job  1
Set up Bun  1
Install dependencies    1
Enter fullscreen mode Exit fullscreen mode

Before the fix, that Build line read 618, inside a 768-second run.
And inside that step, the cost wasn't Astro.
It was sharp, the image library, living its own Groundhog Day and re-encoding the same images on every single run.

On the store site it regenerated 3,100 AVIF and WebP variants each time.
On the blog, 630 variants got encoded on every run, which came to 10 minutes out of 13.
The pattern showed up on 2 repos, 2 days apart, and the same command found it in one pass both times.

Every push, sharp redid homework it had already finished on the previous run.

An average step duration can lie, so look at a few runs before you decide what to fix (a point a vendor guide on speeding up GitHub Actions makes too).

Ship in 140 seconds instead of 13 minutes.

The Solo Dev CI Speed Kit gives you the 1 command that finds your slow step, the cache that removes it, and the 3 workflows ready to adapt.

PDF + ZIP, 19 USD, instant download.

Speed up my CI

Remove the Slow Step by Caching Its Output

The cache that worked holds the derived output: the folder where the encoded images land.
Its key is a fingerprint of everything that can change those images, meaning the source images, the scripts that encode them, and the lockfile that pins the sharp version.
This is the blog's step, with paths shortened:

- name: Cache AVIF conversion
  uses: actions/cache@v6
  with:
    path: node_modules/.cache/avif
    key: avif-${{ runner.os }}-${{ hashFiles('public/blog-images/**', 'scripts/avif-cache.mjs', 'scripts/convert-avif.mjs', 'bun.lock') }}
    restore-keys: avif-${{ runner.os }}-
Enter fullscreen mode Exit fullscreen mode

hashFiles turns those files into the fingerprint.
As long as the images and the scripts stay the same, the key stays the same, and the run pulls the folder back instead of encoding 630 files.
Add 1 image and the key changes, which is where restore-keys comes in: GitHub checks these fallback prefixes in order when there's no exact match, so the run starts from the previous folder instead of an empty one.

The store site uses a slightly different trick.
Its image variants are named after a hash of their source image, so the key only covers the lockfile and the image script, plus the run ID:

- uses: actions/cache@v6
  with:
    path: site/.image-cache
    key: images-${{ runner.os }}-${{ hashFiles('bun.lock', 'cli/modernImages.ts') }}-${{ github.run_id }}
    restore-keys: |
      images-${{ runner.os }}-${{ hashFiles('bun.lock', 'cli/modernImages.ts') }}-
      images-${{ runner.os }}-
Enter fullscreen mode Exit fullscreen mode

The run ID at the end gives every run its own key, so each run saves the latest state of the folder, and the next run restores it through the prefixes.

The store site went from 457-698 seconds down to 255.
The blog went from 768 seconds to 140, with its build step down from 618 to 102.

Those blog numbers are warm-cache numbers.
As of November 2026, GitHub deletes caches that haven't been accessed in more than 7 days, so a repo you leave alone for a week pays the full encoding again on its next run.

If you'd rather start from a cache that already works, you can cut your own slow step with the same pattern and ready-to-adapt workflows.

The wait was fixed at that point.
Splitting work into parallel jobs promises to shave off even more, and it bills in a way the run duration doesn't show.

Know What Parallel Jobs Really Cost Before You Add Them

On the store site, I split the single job into 2 jobs running in parallel.
The run dropped from 8 minutes to 5.
The billed minutes went from 9 to 11.

Your wait is the slowest job, but your bill counts every job.
Each parallel job also boots its own runner and redoes its own checkout and setup before any real work starts.

I bought back 3 minutes of waiting with 2 minutes of billing.

On a private repo, parallel jobs are a trade, not a free optimization, and whether it's worth it depends on how often you push.
I already learned how fast those minutes go in July, when my agent burned through my Actions quota mid-migration.

1 job of 8 minutes billed 9 minutes, next to 2 parallel jobs of 5 minutes billed 11 minutes


Parallel jobs finish sooner but bill more

The wait went down, the bill went up, and the numbers alone don't decide which one matters more.

Catch the Build That Fails Behind a Green Status

The same blind spot showed up on a third project.
My private dashboard's production build on Vercel was failing, and no page ever looked broken.

The new build failed for 13 days and the old one kept serving, unbothered: Schrödinger's deployment, broken and serving at the same time until somebody opens the box.

The only things living on Vercel are machine endpoints (the MCP server, OAuth, and the public keys other services use to check tokens), so the stale version looked fine to everything that called it.

The cause was 4 long-running routes that declared a maxDuration of 540 to 900 seconds, while Vercel's hobby plan caps it at 300.
The fix moved those routes out of the Vercel build with a file suffix that only my own server's build picks up, so the server keeps its long timeouts and Vercel builds again.
A green status was hiding a failure that no step was timing.

3 Rules for a Faster CI

  1. Time every step before you cache anything. On both repos, the framework build was one of the cheapest lines in the output.
  2. Cache the derived output folder, not the tool. Key it with hashFiles on its sources and on the script that produces it, and add restore-keys so a change starts from the last folder instead of an empty one.
  3. Price parallel jobs against your minutes bill before you add them. The wait shrinks to the slowest job, and the bill still counts all of them.

Deploy more often, wait less on every push.

The Solo Dev CI Speed Kit gives you the measurement, the cache pattern, the deploy-key setup and the lint ratchet, with the files already written.

PDF + ZIP, 19 USD, instant download.

Get the CI Speed Kit

Where This Stops Applying

These are 2 private repos with image-heavy builds, and the best numbers come from a warm cache.
If your pipeline doesn't generate derived files (images, search indexes, compiled assets), there's no output folder to cache, and your slow step is something else.
The step timings will show you which one.
Nothing here predicts what you'll gain on your own repo.

The wait is fixed on both repos.
On the store site, 2 parallel jobs took 3 minutes off the wait and added 2 to the bill.
The question left on my desk is a different one: what is a minute of my waiting worth against a billed minute?

GitHub's API times every step. It doesn't put a price on that one.

Sources

Top comments (0)