HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 962 min read

Build Cache Invalidation — Timestamps vs Content Hashes

Timestamps vs Content Hashes

Developer toolingdeveloper tooling / build systems

The myth

"My build is incremental, so it only rebuilds what actually changed."

Actually

Classic make has no idea what changed. It compares mtime — one number per file — and rebuilds a target if any prerequisite is newer than it. That's the whole algorithm. File contents are never read.

Three consequences you've already lived through:

  • git checkout other-branch && git checkout - sets mtime = now on every touched file. Full rebuild, even though the tree is byte-identical to what you compiled 30 seconds ago.
  • touch main.c → rebuild. Add a comment and delete it → rebuild.
  • The scary one, inverted: if a source is written in the same clock tick as its .o, make sees "not newer" and SKIPS it. You ship a binary built from code you already deleted. Filesystems with 1-second timestamp granularity (some NFS mounts, certain CI volume drivers) hit this. ext4 is nanosecond — which is why it's rare on your laptop and not rare in CI.

The other model: content hashing

Bazel, Gradle, Nx, Turborepo, ccache/sccache never ask "is it newer?" They ask "have I seen exactly these inputs before?"

key = hash(source bytes + compiler flags + toolchain version + declared env)

Revert your change → same hash → instant hit. Build on a teammate's machine → same hash → remote cache hit. Timestamps are irrelevant.

The gotcha that separates intermediate from beginner

Hash-based caching is correct only if EVERY input is declared. An undeclared input doesn't make you slow — it makes you wrong, silently.

Classics:

  • $CC pointing at a different gcc minor version, or a system header outside the sandbox
  • A build-time env var (NODE_ENV, GIT_SHA) not folded into the key
  • __DATE__ / __TIME__ in C — exactly why ccache ships CCACHE_SLOPPINESS=time_macros
  • A test task whose key covers src/ but not fixtures/ → green "cache hit" on a test that never ran against the new fixture

Timestamp staleness costs you minutes. Hash staleness costs you a bad release.

Commands worth having in your fingers

make --debug=b        # prints WHY each target was considered out of date
ccache -z && make && ccache -s     # zero stats, build, read hit rate
ls --time-style=full-iso -l main.c main.o
bazel aquery 'deps(//app)'         # the exact declared inputs per action

Your exercise

In any repo with a Makefile (or a ccache-backed build):

  1. Build clean, then build again. Confirm the second one is a no-op.
  2. touch one source file — don't edit it. Build. Did it rebuild? Say out loud why.
  3. ccache -z, build from scratch, ccache -s. Then make clean, build again, ccache -s.

What's the hit rate on that second build, and what does that number prove about mtime vs content?

Bonus, harder: name one input your CI build genuinely depends on that is NOT in your cache key today.