← All posts
7 min readThe Cachely team

What makes a good build cache, and how Nx compares

Caching is the oldest trick in computing: do the work once, remember the answer, skip the work next time. It is also one of the easiest things to get subtly wrong. A cache that returns the wrong answer is worse than no cache at all - it is a bug that hides behind a green build. Here is how to think about a build cache properly, the questions worth asking of any tool, and where Nx fits into the broader picture.

The one rule a cache must never break

Every cache rests on a single promise: the same inputs always produce the same key, and a key only ever maps to the output those inputs would have produced. Break that promise and you do not get a slow build - you get a wrong one. So the first question for any cache is not "how fast is it?" but "how confident am I that a hit is correct?"

Two failure modes follow from that rule, and they are opposites:

  • Under-keying (false hits). The key ignores something that actually affects the output - a compiler version, an environment variable, a generated file. You get a hit that should have been a miss, and ship a stale or wrong artifact.
  • Over-keying (false misses). The key includes something irrelevant - an absolute path, a timestamp, the machine's hostname. Every build looks unique, the hit rate collapses, and the cache quietly stops paying for itself.

A good cache config is the art of keying on exactly what matters and nothing more. Everything else in this post is a consequence of that one idea.

What actually belongs in the key

Regardless of the tool, a correct cache key is a hash of the complete set of inputs to a unit of work. For a build task that usually means the source files it reads, the dependencies it pulls in, the exact command and its flags, and the slice of the environment that changes the output (the toolchain version, relevant env vars). If an input can change the result, it has to be in the key. If it cannot, keep it out.

The classic mistakes are all about getting that boundary wrong: hashing the system clock, hashing an absolute path that differs per machine, or hashing a lockfile only loosely so a dependency bump sneaks through. Determinism is the prerequisite - if running the same task twice produces different bytes (embedded timestamps, non-reproducible archives, random ordering), no cache can safely reuse it.

Questions to ask of any cache

Before you trust a cache - local or remote, Nx or anything else - it is worth being able to answer these:

  • What is in the key? Can you enumerate the inputs? A cache you cannot reason about is a cache you cannot trust.
  • How is correctness verified? Are artifacts content-addressed (the key derived from the bytes) so a corrupted or tampered entry cannot pose as a valid one?
  • What is the invalidation story? When an input changes, does the old entry become unreachable automatically, or do you have to remember to bust it by hand?
  • Who can write to it? A shared cache is a place other people's builds put bytes your build will trust. That is a security boundary, not just a performance one.
  • What does a miss cost? A good cache fails safe: a miss is a rebuild, never a broken build. Treat any tool that hard-errors on a cache problem with suspicion.

Local versus remote, and why sharing raises the stakes

A local cache only ever serves the machine that wrote it, so a bad entry hurts one developer. A remote cache shares entries across the whole team and CI, which is exactly where the speed-up comes from - and exactly why correctness and trust matter more. The moment a cache is shared, "who can write to it" becomes a real question, because a single poisoned entry now fans out to everyone who hits that key.

This is why we keep coming back to read-only versus read-write access. Builds you do not fully trust (pull requests, forks) should be able to read the cache for fast feedback but never write to it; only trusted builds populate it. That single split neutralizes a whole class of cache-poisoning attacks - we go deeper on that in the CREEP write-up.

Is the Nx cache config the same as everyone else's?

The concepts are universal; the configuration surface is not. Every serious build cache hashes inputs into a key and stores outputs under it - Nx, Turborepo, Bazel, Gradle, ccache, your CI provider's cache step, even the HTTP caches in front of your site all share that DNA. What differs is how much the tool figures out for you versus how much you declare by hand.

  • Nx infers a task's inputs from the project graph and a set of named input groups, and you tell it which files an output depends on via namedInputs and a task's inputs/outputs. Get the inputs wrong and you get the under/over-keying failures above - so the config is mostly about describing your inputs accurately.
  • Turborepo uses a similar idea with inputs/outputs per task in turbo.json, plus explicit globalDependencies for things every task depends on.
  • Bazel takes it to the extreme: you declare every input and output explicitly, and it sandboxes execution so an undeclared input simply is not visible. More work up front, very few false hits.
  • Generic CI caches (the "cache this folder against this key" step) push the entire burden onto you - you hand-write the key, and a forgotten input is a silent stale hit waiting to happen.

So: the mental model transfers one-to-one, but you cannot copy a key definition from one tool to another. The thing that is portable is the discipline - know your inputs, keep the key tight, make artifacts immutable, and lock down who can write. A remote cache like Cachely is just a storage and access-control layer underneath whichever tool computes the keys; it speaks the Nx protocol but the principles it enforces (content-addressed, immutable, read-only by default) are the same ones you would want behind any cache.

Once the model is solid, use this CI speed optimization checklist to turn those principles into faster day-to-day pipelines.

A short checklist

  • Make the task deterministic first - no cache can fix non-reproducible output.
  • Key on every real input, and nothing irrelevant. Audit for both false hits and false misses.
  • Prefer content-addressed, immutable artifacts so an entry cannot be silently overwritten.
  • Make invalidation automatic - changing an input should change the key, not require a manual flush.
  • On a shared cache, separate read from write and scope tokens per actor.
  • Fail safe: a cache problem should degrade to a rebuild, never a broken build.

A remote cache that gets the fundamentals right

Content-addressed, immutable artifacts. Read-only by default. Per-workspace isolation. Free tier - no credit card.