Why Is My Cache Restore Slow?

Cache restore time tracks archive size and the link between runner and store. Break-even arithmetic, three trims that shrink the entry, and how to measure it.

A cache restore is a network transfer followed by a decompression, so its duration tracks two things: the size of the compressed archive and the speed of the link between the runner and the cache store. Once the archive grows past the point where moving those bytes costs more runner minutes than regenerating the files would, the entry loses time on every job that restores it.

Answer

Both halves of the round trip are billed as runner minutes, because the runner sits idle waiting on the transfer. A workflow that restores 8 GB at the start of the job and writes it back in the post-run save step is paying for the same bytes twice per run, whether or not the build used them.

Write the rule down before touching any keys. Let A be the compressed archive size, R the effective restore rate you measure end to end (download plus decompression), and B the wall clock time to rebuild the same paths from scratch. On a run that only restores, the entry is worth having when B > A / R. On a run that restores and then saves, the same bytes move twice, so the threshold doubles to B > 2A / R.

The table below works that arithmetic at two assumed rates. Substitute the rate from your own job log using the measurement steps in the next section; 1 GB is treated as 1000 MB throughout, and the runner cost column prices the restore on warp-ubuntu-latest-x64-4x at $0.008 per minute, from the pricing page, checked on 2026-08-13.

Compressed archiveRestore at 100 MB/sRestore at 250 MB/sRebuild time needed to break even (restore-only run, 100 MB/s)Break-even on a run that also savesRunner cost of one restore at 100 MB/s
200 MB2 s0.8 sover 2 sover 4 s$0.0003
1 GB10 s4 sover 10 sover 20 s$0.0013
3 GB30 s12 sover 30 sover 60 s$0.0040
5 GB50 s20 sover 50 sover 100 s$0.0067
10 GB100 s40 sover 100 sover 200 s$0.0133
20 GB200 s80 sover 200 sover 400 s$0.0267

Two readings follow from the shape of that table. Small entries clear the bar easily, which is why a 200 MB package manager download cache almost always pays for itself. Large entries need a rebuild step measured in minutes to justify themselves, and most repositories that restore 10 GB or more are carrying directories the build could regenerate in seconds.

Hit rate multiplies the whole calculation. An entry restored on 40 percent of runs delivers 40 percent of the saving while paying its transfer cost on every run that writes it, so measuring hit rate belongs in the same pass as measuring restore time.

Detail

Measure the restore before you change anything

Three sources give you the numbers, and none of them requires a new tool.

The GitHub Actions run view is the per-step timing view. Each step in a job carries its own elapsed time, including the restore step and the post-run save step that GitHub appends automatically for cache actions, which appears at the bottom of the step list named after the step that created it. Read the restore, the save, and the install or build step that regenerates the same paths off a single run, and the comparison the break-even rule needs is already on screen. For the detail inside a step, rerun with debug logging enabled.

Entry sizes come from the GitHub CLI, sorted heaviest first:

gh cache list --sort size_in_bytes --limit 30

Job-level trends come from the WarpBuild Reports page. The Jobs section aggregates every unique repository, workflow, and job name combination with Duration P75 and P90 and Queue Time P75 and P90, and every tab exports to CSV. That is the surface for answering whether last week's cache change moved the P90, rather than whether one run looked better. CI observability is part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, and CPU and memory columns in Reports require observability to be enabled.

Trim one: stop caching what the build regenerates

The largest entries usually hold two kinds of files: downloads that came over the network once, and outputs a local command can rebuild. Cache the first kind, drop the second.

For Node, that means caching ~/.npm rather than node_modules. The download cache is compact and stable, and npm ci reconstructs the tree from it without a network round trip. The same split applies elsewhere: the Go module cache rather than the build output tree, the Gradle dependency cache rather than every build/ directory, the pip wheel cache rather than the virtual environment. Log directories, test reports, coverage output, and editor artifacts add bytes to every restore and are never read by the next run.

Exclusions are worth checking after each change, because a path pattern that once matched 400 MB can quietly match 4 GB a year later.

Trim two: split one entry into two

A single key covering dependencies and build output means one changed lockfile rewrites the whole archive. Splitting the paths into two entries with separate keys means the slow-moving half stays warm while the fast-moving half rotates, and jobs that need only one half restore only that half.

name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v5

      - name: Restore dependency downloads
        id: deps
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-

      - name: Restore build output
        uses: WarpBuilds/cache@v1
        with:
          path: .next/cache
          key: next-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            next-${{ runner.os }}-

      - run: npm ci
      - run: npm run build

WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and takes the same path, key, and restore-keys inputs, per the caching documentation. Two inputs matter for restore time specifically. lookup-only: true checks whether an entry exists without downloading it, which suits a gate job that decides whether later jobs need to rebuild. fail-on-cache-miss: true turns a silent fallback into a failed step, which is the right setting on a job that should never rebuild from source.

Trim three: drop the stale entries

Keys built on a lockfile hash write a full new archive whenever the lockfile changes, and the superseded copies stay in storage until they age out. Worse, a restore-keys prefix fallback can restore one of those older copies into a working directory, after which the save step writes the union back under the new key. Entries grow generation over generation and restores get slower with no change to the repository.

Three fixes apply. Delete entries you know are dead, using the delete-cache input on WarpBuilds/cache or the WarpBuild console. Narrow the restore-keys prefix so a fallback can only match a recent generation. Let time do the rest: WarpBuild cache entries expire 7 days after their last use, per the caching documentation, so an abandoned key clears itself. The cache size limit guide covers the related failure, where GitHub's default 10 GB per-repository ceiling evicts entries in least recently used order and a key that hit yesterday misses today.

What the archive costs per month

Storage is the smaller line. The runner minutes spent moving the archive are the larger one. Both come from the pricing page, checked on 2026-08-13: cache storage is $0.20 per GB-month, cache write and restore operations are $0.0001 each, and both are free on BYOC runners.

Compressed archiveStorage per monthRestore minutes per 1,000 jobs at 100 MB/sRunner cost of those minutes on warp-ubuntu-latest-x64-4x
1 GB$0.20167$1.33
5 GB$1.00833$6.67
10 GB$2.001,667$13.33
20 GB$4.003,333$26.67

Cache operations add $0.20 per 1,000 jobs at two operations per job, one restore and one save, and that figure does not move with archive size. At 20 GB the runner minutes cost about six times the storage, which is the reason to treat a large entry as a time problem first.

When a cache is the wrong mechanism

A cache action moves named paths. State that lives outside a cacheable path, such as installed system packages, container images in the local Docker store, or a warmed compiler directory, is not addressable by path and ends up bloating the archive when teams try to force it in. Snapshot runners boot a job from a saved image of an earlier run's disk instead, which removes the transfer from the job entirely for that class of state. Snapshot runners apply to the Ubuntu part of that catalog, and WarpBuild caching is not supported on Windows runners. The persistent caches guide walks through choosing between the two mechanisms with the same break-even arithmetic used above.

How do I tell whether the restore or the rebuild is slower?

Read the two numbers off one run. The restore step and its matching post-run save step each carry their own elapsed time in the GitHub Actions run view, and the step that regenerates the same paths carries its own. If the restore plus save total exceeds the regeneration step, the entry is costing runner minutes rather than saving them. Pair that with cache hit rate, because a slow restore on a low hit rate is worse than either number suggests on its own, and use the Reports page to confirm the change held at P90 across a week.

Does a larger runner make a cache restore faster?

A larger size is the documented fix for one specific failure, the "Failed to commit cache" error that appears when Docker layers are large and the runner is small, such as layers over 5 GB on a 2x runner, per the caching documentation. For a restore that is slow rather than failing, time the step on both sizes before committing, because warp-ubuntu-latest-x64-4x costs $0.008 per minute against $0.016 per minute for warp-ubuntu-latest-x64-8x, from the pricing page, checked on 2026-08-13. Doubling the rate is worth it only when the restore step shortens by more than half.

Is a 10 GB cache entry too large to be worth restoring?

It depends on what the entry replaces. At an effective restore rate of 100 MB per second, a 10 GB archive takes about 100 seconds to restore and about 200 seconds on a run that also saves it, so it pays for itself only when regenerating those paths takes longer than that. Entries above that threshold usually belong in a split cache, keyed so the slow-moving half stays warm, or on a snapshot runner. The cache size limit guide covers the separate ceiling question, where GitHub caps combined caches in a repository at 10 GB by default and evicts least recently used entries first.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.