DEV Community

Cover image for BuildKit Cache Mounts: A Benchmark That Didn't Go the Way I Expected
Raphael Gab-Momoh
Raphael Gab-Momoh

Posted on Originally published at raphaelgmomoh.pages.dev

BuildKit Cache Mounts: A Benchmark That Didn't Go the Way I Expected

Starting From a Real Failure, Not a Clean Demo

The first attempt at this article used az acr build — the same tool the previous article in this series used to measure image sizes. It failed immediately:

Step 2/2 : RUN --mount=type=cache,target=/tmp/cache echo "buildkit cache mount works"
the --mount option requires BuildKit. Refer to https://docs.docker.com/go/buildkit/ to learn how to build images with BuildKit enabled
Enter fullscreen mode Exit fullscreen mode

Azure Container Registry Tasks (az acr build) uses Docker's classic builder, not BuildKit, by default. This isn't documented prominently anywhere obvious, and it's a real gap in the previous article's own tooling choice — az acr build was the right call for measuring image sizes, but it can't run this benchmark at all. The fix was switching to a GitHub Actions runner, which has real Docker with docker buildx (BuildKit's CLI front-end) available via docker/setup-buildx-action.

Before Step 1, one term this walkthrough leans on:

  • RUN --mount=type=cache — a BuildKit-only Dockerfile instruction that mounts a persistent cache directory into a single RUN step, without that directory becoming part of the final image layer. Unlike ordinary Docker layer caching (which is invalidated the moment any earlier instruction changes), a cache mount's contents persist independently across builds — even across builds that pass --no-cache, which is the property this benchmark actually tests.

Step 1 — Two Dockerfiles, one variable changed

# Dockerfile.no-cache-mount
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
Enter fullscreen mode Exit fullscreen mode
# Dockerfile.cache-mount
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN --mount=type=cache,target=/root/.npm npm install
COPY . .
RUN npm run build
Enter fullscreen mode Exit fullscreen mode

Why only one line differs: isolating the RUN npm install line as the only change between the two Dockerfiles means any timing difference between them can only be attributed to the cache mount — not to a different base image, a different app, or a different dependency set.


Step 2 — The benchmark design, and why --no-cache is the actual test

- name: Warm the npm cache mount (first build, cold)
  run: time docker buildx build --no-cache -f Dockerfile.cache-mount -t cache-mount:1 --load .

- name: Rebuild with --no-cache (mount cache should still be warm)
  run: time docker buildx build --no-cache -f Dockerfile.cache-mount -t cache-mount:2 --load .

- name: Baseline build, no cache mount at all (first build, cold)
  run: time docker buildx build --no-cache -f Dockerfile.no-cache-mount -t no-cache-mount:1 --load .

- name: Rebuild with --no-cache (no mount cache to help this time)
  run: time docker buildx build --no-cache -f Dockerfile.no-cache-mount -t no-cache-mount:2 --load .
Enter fullscreen mode Exit fullscreen mode

Why every build here passes --no-cache: this is the actual point of the benchmark, not an oversight. --no-cache forces BuildKit to discard ordinary layer caching entirely and re-run every instruction from scratch. If the second cache-mount build is still faster than the first despite --no-cache, that speedup can only be coming from the RUN --mount=type=cache directory — proving the cache mount is a genuinely separate mechanism from layer caching, not just another name for the same thing. The no-cache-mount builds are the control group: run twice, also with --no-cache, with nothing that should make the second one faster.


Step 3 — Running it for real and reading the actual timings

gh workflow run benchmark-cache-mount.yml --repo raphgm/docker-multistage-lab
Enter fullscreen mode Exit fullscreen mode

The real results, pulled from the workflow's own logs:

Build Total time (real)
cache-mount, build 1 (cold) 11.769s
cache-mount, build 2 (--no-cache, mount warm) 9.255s
no-cache-mount, build 1 (cold) 9.848s
no-cache-mount, build 2 (--no-cache) 10.283s

At the total-build level, the pattern is there: the cache-mount build got faster on its second run (11.8s → 9.3s, about 21% less), while the no-cache-mount build didn't (9.8s → 10.3s — if anything, slightly slower, within normal noise). That's the expected direction.


Step 4 — The part that didn't match expectations

Looking at just the npm install step in isolation, inside BuildKit's own per-step timers, told a different story:

cache-mount build 1:      RUN --mount=... npm install  ->  3.337s
cache-mount build 2:      RUN --mount=... npm install  ->  3.118s
no-cache-mount build 1:   RUN npm install               ->  3.100s
no-cache-mount build 2:   RUN npm install               ->  3.409s
Enter fullscreen mode Exit fullscreen mode

The npm install step itself took essentially the same ~3.1–3.4 seconds in all four builds, cache mount or not. The total-build-time difference observed in Step 3 is real, but it isn't coming from the npm install step being meaningfully faster — it's coming from noise elsewhere in the build (layer export, tarball transfer, buildx overhead), which varies run to run regardless of caching.

Here's the honest reason this benchmark doesn't show the dramatic effect the docs and blog posts promise: this app has 82 small dependencies, and for a dependency set that size, npm install's time is dominated by registry metadata resolution over the network — a fixed cost that happens whether or not the downloaded package tarballs are cached locally. A cache mount speeds up re-downloading packages; it does nothing for the metadata-resolution round-trip, which is the actual bottleneck at this scale.

RUN --mount=type=cache earns its keep on dependency trees large enough, or with slow/rate-limited enough registries, that skipping repeated downloads is the dominant cost — think hundreds of packages, private registries with authentication overhead, or ecosystems like Python's pip with large binary wheels. For a lean 82-package Node app hitting npm's CDN, the download itself was never the bottleneck, so there was nothing large for the cache mount to save.


Closing Thoughts

The instinct when a benchmark doesn't show what you expected is to either tweak it until it does, or quietly not publish it. Neither is honest. This cache mount benchmark is real — the workflow ran, the timings above are copied directly from GitHub Actions' own logs, not estimated — and the real result is "modest effect, and here's the specific reason why, tied to this app's actual dependency count." That's a more useful thing to know before reaching for --mount=type=cache on a small project than a headline number borrowed from someone else's much larger dependency tree.

GitHub Repository: docker-multistage-lab — includes Dockerfile.cache-mount, Dockerfile.no-cache-mount, and the exact benchmark-cache-mount.yml workflow used to produce every number in this article; re-run it yourself with gh workflow run.

Reviewed against current docker buildx and GitHub Actions behavior as of September 2026.

Docker · BuildKit · Cache Mounts · CI/CD · GitHub Actions


Originally published on my portfolio.

Top comments (0)