Developer Tools

    CI Caching That Actually Works

    Most CI caches are slower than no cache at all. The difference comes down to what you key on, what you store and how honestly you measure the result.

    A cache that hits 40 % of the time and costs 25 seconds to restore is not a speed-up. It is a 15-second tax paid on every run, dressed as an optimisation.

    The reason so many CI caches end up there is that nobody measures them after the pull request that added them.

    Cache keys are the whole design

    A cache key answers one question: is this stored artifact still valid? Almost every bad cache is a bad answer to that question, in one of two directions.

    Too specific and you never hit. Keying a dependency cache on the commit SHA guarantees a miss on every run, and you pay upload cost forever.

    Too loose and you hit with stale content. Keying on the branch name means a lockfile change does not invalidate anything, and you get build failures that disappear when someone clears the cache - the worst debugging experience CI has to offer.

    The correct key is a hash of everything the artifact depends on, and nothing else:

    YAML
    - uses: actions/cache@v4
      with:
        path: ~/.npm
        key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          npm-${{ runner.os }}-

    The restore-keys fallback is what makes this work in practice. On a lockfile change you miss the exact key, restore the most recent prefix match, and npm ci reconciles the difference - a partial hit that is far cheaper than a cold one.

    Cache the input, not the output

    There is a persistent instinct to cache node_modules, .venv or vendor/ directly. It is usually the wrong layer.

    Cached Restore cost Correctness risk
    Package manager cache (~/.npm, ~/.cache/pip) Moderate; install still runs Low - the installer validates
    Installed tree (node_modules) Low; skips install High - platform binaries, partial state

    The installed tree contains compiled native modules built for a specific OS, architecture and runtime version. Restore it onto a slightly different runner image and you get failures that look like application bugs. The download cache contains verified archives, and the installer stays responsible for correctness.

    Cache the installed tree only when you control the runner image exactly and you have measured that install time is actually the bottleneck.

    Measure the cache, not the build

    The number that matters is not the hit rate. It is:

    Text
    saving = hit_rate × (cold_cost − warm_cost) − restore_overhead

    A cache is worth keeping when that expression is comfortably positive. Getting the inputs is a matter of logging four things on every run: whether it hit, how long restore took, how long the cached step took, and how long it takes cold.

    Shell
    start=$(date +%s%N)
    restore_cache
    echo "cache_restore_ms=$(( ($(date +%s%N) - start) / 1000000 ))"
    echo "cache_hit=${CACHE_HIT:-false}"

    Two lines of shell, emitted as metrics. Without them, “the cache is helping” is a belief.

    Common results when teams do this for the first time: the Docker layer cache is worth minutes, the dependency cache is worth tens of seconds, and the test-artifact cache added eighteen months ago has a 6 % hit rate and costs 40 seconds on every miss.

    Compression is a real choice

    Most cache actions default to a general-purpose compressor tuned for ratio. For CI, restore time usually matters more than storage.

    • Zstandard at level 1-3 is typically 3-5× faster to decompress than gzip at a similar ratio. If your cache action supports it, switch.
    • Skip compression entirely for caches under ~50 MB on the same-region storage. The compress/decompress round trip costs more than the transfer saved.
    • Exclude what you do not need. Test fixtures, documentation and source maps inside a dependency cache are pure transfer cost. A well-pruned 200 MB cache beats a 900 MB one every time.

    Docker layers deserve more attention than they get

    For most repositories, the container build is the largest single cost and the most improvable.

    The rule is unchanged since the first Dockerfile: order layers from least to most frequently changed.

    Dockerfile
    FROM node:22-slim
    
    WORKDIR /app
    
    # Changes rarely - cached across nearly every build.
    COPY package.json package-lock.json ./
    RUN npm ci --omit=dev
    
    # Changes every commit - everything above stays cached.
    COPY . .
    RUN npm run build

    Copying the whole source tree before installing dependencies invalidates the install layer on every commit. It is the single most common Dockerfile mistake and it costs more than every other caching decision combined.

    With BuildKit, add a mount cache so even a cold layer reuses the download cache:

    Dockerfile
    RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

    When to delete a cache

    Delete it when the measurement says it is not paying for itself, and do it without ceremony. A cache that saves four seconds is not worth the debugging session it will eventually cause.

    Keep the ones that are worth minutes. Instrument all of them. Re-read the numbers once a quarter, because the shape of the build changes and the cache that was worth two minutes last year may be the reason CI feels slow today.