# actions/cache vs buildx cache: two different systems, measured

> actions/cache vs buildx cache is not one choice with two spellings. What each stores in GitHub Actions, what each returned, and when each wins.

Source: https://latchkey.dev/learn/speed/actions-cache-vs-buildx-cache  
Updated: 2026-09-20

The actions/cache vs buildx cache question sounds like a choice between two settings and is really a choice between two systems: `actions/cache` saves a directory and puts the files back, while a buildx cache backend hands BuildKit layers so it can skip instructions. On one project measured on a Latchkey runner, the first restored an 85 MB dependency tree in 0.374 seconds and the second took a rebuild from 11.9 seconds to 9.4.

Both of these are called a cache, both are configured in a workflow file, and they never do the same job. One is a file-copy service with a key you choose. The other is a store of build results that BuildKit consults while it walks your Dockerfile, keyed on a hash it computes itself.

The confusion costs real time, because the wrong one for a job is not slightly worse, it is useless. So here is what each stores, what each returned on the same project, and the one place they actually collide.

## What each one stores

`actions/cache` takes a list of paths, tars them, and uploads the archive under a key you write. On a later run it downloads the archive and unpacks it back to the same paths. It does not know or care what is inside; a dependency tree, a compiler cache and a directory of holiday photos are the same problem to it.

A buildx cache backend is not a file service at all. It stores the layer blobs and the manifest BuildKit produced, and on the next build BuildKit hashes each instruction, looks for a match, and skips the ones it finds. You never name a key. The build graph is the key.

|  | `actions/cache` | A buildx cache backend |
| --- | --- | --- |
| Stores | A tar of the paths you list | Layer blobs and a manifest |
| Keyed on | The key expression you write | The hash BuildKit computes |
| Gives back | The files, in place | Instructions it can skip |
| Configured with | `path:` and `key:` | `cache-from` and `cache-to` |
| Knows about Docker | No | It is only about Docker |

## What each returned on the same project

We ran both systems in one job against one Node project, 414 packages in the lockfile, and recorded what each stored and what each bought back. The dependency side is small and fast: the archive of an 85 MB `node_modules` tree is 11.3 MB, saving it took 0.322 seconds and restoring it 0.374. The image side is bigger and slower on both counts.

Read the two halves as separate answers to separate questions. Restoring the directory removed a 4.04 second install. Restoring the layers removed 2.49 seconds from an 11.93 second build. Neither number is the other one, and neither of them can be added to the other, because the controls are different: the first is an install with nothing cached, the second is a rebuild on a freshly created builder.

| What was cached | Control | With the cache | Stored |
| --- | --- | --- | --- |
| `node_modules`, restored whole | 4.04 s install | 0.374 s restore | 11.3 MB archive |
| `~/.npm`, then install again | 4.04 s install | 2.33 s install | 12.6 MB archive |
| Image layers, rebuild after a source change | 11.93 s build | 9.44 s build | 98 MB cache |

> Measured by `job-k.sh`, under content/repro/timings/actions-cache-vs-buildx-cache/, on a Latchkey `latchkey-small` runner on 2026-09-20: 2 vCPU, 7,734 MB RAM, Docker 29.7.2, buildx 0.36.1, Node 20.20.2, zstd 1.5.5. One pass per row. The two dependency rows share one control and the image row has its own, so the rows do not add up to a total. The archives were written and read on the runner itself, so they exclude the upload and download a real cache service adds.

## The one place people try to use the wrong one

Caching a built image through `actions/cache` is the mistake that looks reasonable: save the image with `docker save`, restore it next run, load it back. We measured it. The image was 73,041,636 bytes, the tarball 71,344 KB, and compressing that tarball took it to 71,120 KB, a saving of three tenths of one percent, because the layers inside it are compressed already.

So this approach moves about 73 MB through the cache on every run that hits and on every run that misses, and gives you nothing to skip: the image either matches your build or it does not. The layer cache moves 98 MB once and then lets BuildKit skip individual instructions, which is what you actually wanted. Use the layer cache for layers.

```Terminal
# what people reach for, and what it moves
docker save app:ci -o image.tar     # 71,344 KB
zstd image.tar                      # 71,120 KB, 0.3% smaller
```

> From `job-k.sh`. Saving the tarball took 1.49 s and loading it back 0.80 s on the same machine, so the cost is almost entirely the bytes a cache service would have to carry both ways.

## Where they collide: the 10 GB cap

These two systems do not compete until you pick `type=gha` as your buildx backend. That backend stores its layer blobs inside the Actions cache for the repository, which GitHub documents as 10 GB by default, and which your `actions/cache` entries are already spending from. On our small project that would be 98 MB of layers beside 11.3 MB of dependencies; on a real image it is the layers that fill the budget.

The failure is silent and it does not look like a Docker problem. GitHub removes the least recently used entries when the repository crosses the cap, so what you see is a Gradle or npm cache missing in a job that never touches Docker. If your image cache is large, put it in a registry and leave the 10 GB to dependencies.

```.github/workflows/ci.yml
- uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v7
  with:
    context: .
    cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
    cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
```

> GitHub documents the limit as 10 GB per repository by default, raisable by an owner or administrator, with any usage beyond 10 GB billed to your account. The job needs `permissions: packages: write` to push the cache image.

## Which one for which job

The rule is short. If the thing you want back is a directory the next job will read, that is `actions/cache`. If the thing you want back is work BuildKit would otherwise redo, that is a buildx backend. Nothing you cache with one of them is reachable by the other.

| You want to skip | Use | Why |
| --- | --- | --- |
| A dependency install | `actions/cache` | It is a directory, not a layer |
| A compiler or linter cache | `actions/cache` | Same, and the key is yours to pick |
| Docker instructions that did not change | A buildx backend | Only BuildKit can match them |
| Pulling a base image repeatedly | A registry mirror | Neither cache is the tool |
| A whole built image | Rebuild with a layer cache | 71 MB moved to skip nothing |

## You can use both, and usually should

They sit at different points in the job, so the normal shape is both at once: a dependency cache for the steps that run on the runner, and a layer cache for the image build. The only coordination they need is the one above, which is to keep the layer cache out of the 10 GB when the image is large.

Latchkey publishes a swap for each half of that pair, which is the shape worth noticing whichever provider you use. Its documentation says to "Replace `actions/cache` with `latchkey-dev/cache-action@v1`" for the directory half, and `latchkey-dev/docker-cache-action@v1` wraps the build for the layer half against a registry provisioned per organization. The documented terms are isolation per organization and a 14-day retention for the dependency cache; no size cap is published, so that is a question to ask rather than an assumption to make.

```.github/workflows/ci.yml
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('**/package-lock.json') }}

- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
  with:
    context: .
    cache-from: type=gha
    cache-to: type=gha,mode=max
```

## What we ran, so you can disagree with it

One script, `job-k.sh`, run once on a Latchkey `latchkey-small` runner on 20 September 2026, committed under content/repro/timings/actions-cache-vs-buildx-cache/ with its unedited `job-k.log` and a status file naming the job id, the runner size and the exit code. The fixture is the same 414-package Node project the earlier Docker measurements used, so the build numbers here sit beside those.

Two things the log does not support. The script also timed a cache export at 4.56 seconds, and that row is not quoted anywhere on this page: it ran on the builder left over from the build before it rather than on a fresh one, so it measured a warm build plus an export and not an export. And `type=gha` was never exercised, because the log records `ACTIONS_CACHE_URL` unset outside a GitHub-hosted job, so everything said about it here is from Docker and GitHub documentation read on the same day.

One pass per row, which means a difference under about half a second is not a real difference. That covers the two sub-second archive rows, where the point is the order of magnitude rather than the digits.

## FAQ

### Can actions/cache cache Docker layers?

Not usefully. It can carry a `docker save` tarball, which on our project was 71,344 KB and compressed to 71,120 KB, and gives BuildKit nothing to skip. A buildx cache backend stores the layers themselves, and on the same project it took a rebuild after a source change from 11.93 seconds to 9.44.

### Does type=gha use my Actions cache storage?

Yes. It stores its layer blobs in the Actions cache for the repository, which shares the documented 10 GB default with every `actions/cache` entry you keep. The symptom of crowding it out is a dependency cache missing in a job that has nothing to do with Docker, because GitHub evicts the least recently used entries once the repository crosses the cap.

### Is a registry cache better than the GitHub Actions cache backend?

For a large image, yes, mostly because of where the bytes live. A registry cache costs registry storage and credentials and leaves the 10 GB repository budget to dependencies, and it can be shared across repositories. For a small image `type=gha` needs no setup at all, which is a real advantage while the cache stays small.

### Do I need both a dependency cache and a Docker layer cache?

If your job both installs dependencies on the runner and builds an image, yes, because they skip different work. Our measurements put the dependency restore at 0.374 seconds against a 4.04 second install, and the layer cache at 2.49 seconds off an 11.93 second build. Neither one covers the other.

## References

- [Docker Docs: cache storage backends (verified 2026-09-20)](https://docs.docker.com/build/cache/backends/)
- [Docker Docs: GitHub Actions cache backend (verified 2026-09-20)](https://docs.docker.com/build/cache/backends/gha/)
- [GitHub Docs: dependency caching, limits and eviction (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [actions/cache: inputs, outputs and cache-hit (verified 2026-09-20)](https://github.com/actions/cache)
- [Latchkey documentation: Docker layer caching (verified 2026-09-20)](https://latchkey.dev/documentation/docker-layer-caching)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
