2026-10-30 · 9 min read

A Cache Is a Dependency Claim, Not a Speed Button

Flat isometric diagram of build stages on a dark background, with one indigo cube's output looping back into its own input via a violet arrow and a single green cube marking the only successful cache hit.

Deno 2.9 shipped a build cache for tasks, and one developer's measurement of it produced a number that marketing teams dream about: a four-second build step dropped to under ten milliseconds. Same command, same output, roughly 423x faster, on stable behaviour with no experimental flags. Then he pointed the same cache at a different task and it made things slower — around 32 milliseconds of overhead against 10 milliseconds to just run the thing.

Both numbers are real. Neither one is the interesting part. The interesting part is that a cache is not a feature you switch on. It is a claim you make about your code, and the tool either gets to trust that claim or quietly burns your time because of it.

Here is what that claim looks like in practice, how it fails silently in two opposite directions, and how to work out which side of the line each of your build steps sits on.

What Deno actually shipped

Deno's task runner reads a tasks block in deno.json. As of 2.9, a single task can declare three extra things: a files glob (its inputs), an output glob (the files it produces), and an env list (environment variable names whose values should feed the key). A glob is just a path pattern — something like "src/**/*.txt" meaning every text file under src. Declaring files is what turns caching on at all; a task with no files field always runs.

The mechanics are the mechanics of every content-addressed cache. Deno computes a hash of the matching files — a short code derived from their bytes — and stores a manifest in the user's cache directory. On the next run it recomputes and compares. On a hit, the command never spawns: no shell, no interpreter, no re-reading of your source. If you deleted the output directory, the declared outputs are pulled back out of the cache store instead of being regenerated. The developer behind the write-up checked both halves by deleting the build directory and watching the artifacts reappear with the command's log file untouched.

The test also settled a suspicion people have about caches built on file metadata. The manifest stores a size and modification time for every tracked file, which invites the assumption that those are what decide a hit. They aren't. Touching a file without changing its contents still produced a hit; appending one line produced a miss. Size and mtime act as a cheap pre-filter to decide whether a file is worth reading, and the content hash is the verdict. That is the right design, and it matters for what comes next.

A cache is a claim about dependencies

Every cache makes one bet: the inputs I listed fully determine the output. That bet can land in only three places, and two of them are bad.

  • Under-declared. The task reads something you didn't list. You get a hit when you shouldn't, and the cache serves an artifact built from files that have since changed. This is the catastrophic one — stale outputs, tests that pass against code you no longer have, a deploy that doesn't match the commit.
  • Over-declared. The task reads less than you claimed, or writes into the same tree it reads. The cache stays correct but almost never hits. You pay the check on every run and get nothing back, and nothing in the tool tells you.
  • Accurate. It hits when it should, misses when it should, and the check is cheap relative to the work being skipped. Only here does the feature earn its place.

The over-declared case deserves the attention, because it looks like a tool problem rather than a design problem. It looks like "the cache is broken" when the truth is "your declaration is dishonest and the tool obeyed you."

The silent failure: a task that writes its own inputs

A task whose command appends to a file inside its own input glob can never hit twice in a row. Every execution changes the very bytes the fingerprint is computed from, so every execution invalidates the next one. Run it three times and you get three executions and three appended lines, with no warning from the tool.

That example is contrived. The shape isn't. You see it whenever a build step writes a log, a report, or a generated index into the source directory it also scans; whenever a generator stamps a build timestamp into a file that sits beside its templates; whenever a formatter rewrites the same files it lists as inputs, which only works if the rewrite is idempotent — and you have to actually prove that, not assume it.

From the outside it reads as a product defect. Your CI logs say the step ran, again, for the fortieth time, and you conclude the feature is half-baked. It isn't. You handed it a task that mutates its own evidence.

The rule and the audit: a task's outputs must never intersect its inputs. Concretely, expand the input glob and the output glob, and intersect the two sets. A non-empty intersection means your cache declaration is decorative at best and dangerous at worst. The second check takes ten seconds: run the task twice with no edits in between. The second run must report a cache hit. If it doesn't, you have a self-invalidating task, a source of nondeterminism in the output (timestamps, random IDs, hostnames), or an environment variable that changes every shell — all three are fixable, none of them will announce themselves.

The other silent failure: checking costs more than doing

Now the numbers that argue against the headline. Point the same feature at a 3,000-file, 12-megabyte input tree and attach it to a task that does nothing but print a word. With file tracking, each run cost about 32 milliseconds. With no tracking at all, the same task cost about 8 to 11. Checking whether anything changed was three to four times more expensive than just doing the work.

The arithmetic is simple enough to carry around. Let T-run be the time the task takes when it actually executes, T-check the time to fingerprint, and p the fraction of runs that hit the cache. Caching wins when T-check < p × T-run, which rearranges to T-run > T-check ÷ p. With a 32ms check and a 90% hit rate, any task longer than roughly 36 milliseconds comes out ahead. At a 50% hit rate — realistic in a dev loop where you are editing the inputs all day — the bar moves to about 64 milliseconds. A four-second hashing job clears that by two orders of magnitude. A 20ms lint pass does not.

Two multipliers make this worse than it looks. First, p is not a property of the tool; it's a property of your habits. On a fresh CI checkout with a cold cache, p is zero and the fingerprint is pure overhead — and because every file has a new mtime, the cheap pre-filter can't skip a single read, so you pay full content hashing. Second, stat and read calls are not free everywhere. On a network filesystem, a container bind mount, or a synced folder, a single metadata call can cost ten to a hundred times what it costs on a local SSD. At 3,000 files and 0.1ms per call, you've spent 300 milliseconds before hashing a single byte.

How to decide, task by task

  1. Measure the run time first. Under about 100 milliseconds, don't cache it. You're adding a failure mode to save noise.
  2. Write down what it reads, honestly. If you can't enumerate the inputs, you can't cache the task. Split it into a cheap "generate" step and an expensive "compile" step so each one has a tight, truthful input set.
  3. Intersect inputs against outputs. If the answer isn't empty, move the logs and generated files out of the read tree before you declare anything.
  4. Put the toolchain in the key, not the whole shell. The language version and the compiler version belong in the env list. Your terminal's unrelated variables do not, or you'll invalidate on every machine.
  5. Watch the miss rate, not the best case. Grep your logs for the cached line. If it never appears, you don't have a cache — you have a timer that fires before every build.

This is not a Deno story

The same claim shows up in Docker layer caching, in your CI provider's cache action, in Bazel and Gradle, in a memoized function inside your own service, in HTTP cache headers, and in whatever your CDN decided about your assets last Tuesday. Every one of them is a bet that the listed inputs determine the output, and every one of them fails the same two ways.

Bazel's entire design philosophy is an argument about how much that bet is worth. It makes the claim true by force — hermetic sandboxes, declared inputs and outputs, refusing to run when a declared input is missing. Deno's version trusts you instead. That's a defensible trade: hermeticity is expensive to set up and slows everything down, and most teams don't need a build system that polices them. But if you take the trust, you take the failure mode too, and the failure mode is silence.

Three questions work at every layer of the stack. What does this actually read? Does it write anything it reads? And what does checking cost relative to doing? Get those right and the cache pays you. Get them wrong and you've built a very fast way to do nothing, or a very quiet way to ship the wrong artifact.

Key Takeaways

  • A cache is a dependency claim, not a performance setting. The tool enforces your description of the task, not the truth about it.
  • Under-declared inputs give you wrong artifacts. Over-declared inputs give you a cache that never hits. Both are silent.
  • The most common silent killer is a task that writes into the tree it reads. Outputs must never intersect inputs — check the globs, then run the task twice and confirm the second run is cached.
  • Fingerprinting is real work. Caching only pays when the task's runtime exceeds check time divided by hit rate; on a large tree over a network filesystem, that bar can be hundreds of milliseconds.
  • For tasks under roughly 100ms, skip the cache entirely. Removing a failure mode beats saving a rounding error.
  • Track your miss rate like a production metric. A cache you never measure is a cache you can't trust.

[ Call to action ]

Ready to scale your engineering team?

Tell us the roles you need to fill and we'll get back within 24 hours.