Cache misses on locale artifacts in a monorepo

The workspace has remote caching, every other task hits, and the localization tasks re-run on every build — extraction, compilation, gates, several minutes each time, on pull requests that did not touch a single string. The cache is not broken. It has been told that these tasks depend on things that change constantly.

Four input declarations that guarantee a cache miss Declaring every file as an input means any commit invalidates the task. Including the package output directory makes the task depend on its own result. Declaring generated target catalogues as inputs means a daily translation sync invalidates extraction. And writing a timestamp into the output means the content hash never repeats. Why a localization task never cache-hits Declared input Why it always changes **/* everything any commit invalidates it the whole package dir including dist/ its own output is an input generated catalogues target files translations land daily a timestamp in the output the file differs each run the hash never repeats
All four look like caching bugs and none of them are — the cache is doing exactly as told.

Root cause: a task is only as cacheable as its declared inputs

An input-based cache computes a hash over the declared inputs and reuses the previous output when the hash repeats. That is the entire mechanism, and it means a task with over-broad inputs is a task whose hash never repeats.

Localization tasks attract broad declarations for a specific reason: their inputs genuinely span package boundaries. Extraction reads application source from several packages and writes into a different package, which makes the tempting declaration “the whole workspace”. That declaration is correct in the sense that it is not missing anything, and useless in the sense that every commit invalidates it.

The second, subtler cause is a task whose output is inside its input set. If extraction writes into packages/i18n-messages/src/locales/en and the compile task declares src/** as its input, then a translation sync — which writes target catalogues into the same tree — invalidates extraction as well, even though extraction does not read target catalogues at all.

The localization task graph Extraction reads source files and writes the source catalogue. Translation fills the target catalogues. Compilation reads both catalogues and writes compiled output. Each stage depends only on what precedes it, and declaring anything wider makes every downstream task uncacheable. The dependency the graph must express source files apps + packages extract source catalogue en/*.json translate target catalogues de, fr, ja compile compiled output dist/*.js Extraction depends on source only. Compilation depends on catalogues only. Neither depends on the other direction.
Each arrow is a dependency; anything not on an arrow must not appear in the task inputs.

Minimal reproducible example

// turbo.json — every localization task misses, every time
{
  "tasks": {
    "i18n:extract": {
      "inputs": ["**/*"],                 // the whole package, including its own output
      "outputs": []                        // no outputs declared: nothing is cached
    },
    "i18n:compile": {
      "inputs": ["../../**/*.{ts,tsx,json}"]   // reaches across the workspace
    }
  }
}

Two independent defects. The first task declares no outputs, so there is nothing for the cache to restore even when the hash matches. The second reaches across the entire workspace, so any change anywhere invalidates it.

The fix: declare the real dependency graph

{
  "tasks": {
    "i18n:extract": {
      "inputs": [
        "../../apps/*/src/**/*.{ts,tsx}",
        "../../packages/ui/src/**/*.tsx",
        "extract.config.json"
      ],
      "outputs": ["../i18n-messages/src/locales/en/**"]
    },
    "i18n:compile": {
      "dependsOn": ["i18n:extract"],
      "inputs": ["src/locales/**/*.json", "compile.config.mjs"],
      "outputs": ["dist/**"]
    },
    "i18n:gate": {
      "dependsOn": ["i18n:compile"],
      "inputs": ["src/locales/**/*.json", "gate.thresholds.json"],
      "outputs": []
    }
  }
}

Three properties make this work. Every task declares outputs, so a hit has something to restore. Inputs name only files the task actually reads — extraction never reads target catalogues, so they are absent. And the ordering is expressed through dependsOn rather than through overlapping globs, which is what keeps each hash independent.

Finding the input that invalidates a task The task runner reports which input changed, comparing input hashes between two otherwise identical runs isolates it, generated directories appearing in the input set are the usual culprit, and narrowing the globs is confirmed by running twice and requiring the second run to hit. Diagnosing a miss in four commands 1 Ask why it missed the runner reports the changed input 2 Compare the input hashes between two identical runs 3 Look for generated files in the input set dist, .cache, coverage 4 Narrow the globs and re-run twice the second run must hit
Running twice is the whole test — a task that misses on an unchanged repository is misconfigured.

The generated-file trap

The most persistent version of this bug involves files that are generated during the build and then included in a later task’s input set. It is persistent because the input declaration looks completely reasonable.

A common shape: a codegen step writes typed keys into src/generated/keys.ts, and the application’s build task declares src/** as input. The generated file’s content depends on the catalogue, so any string change invalidates the application build — which is correct — but the file is also rewritten on every run even when its content is identical, and if the generator writes a header comment containing a timestamp, the content differs every time and nothing downstream ever hits.

Two fixes apply. The generator should be deterministic: no timestamps, no absolute paths, stable key ordering. And generated directories should be excluded from broad input globs and declared explicitly as outputs of the task that produces them, so the graph knows where they come from.

Determinism is worth a moment of attention because it is easy to lose. Sorting an object’s keys, formatting numbers identically, and writing line endings consistently all sound trivial and each has broken a cache somewhere. A quick check is to run the generator twice against an unchanged catalogue and diff the outputs; anything that differs is a cache defeat waiting to happen.

What the misses are costing

It is worth quantifying before deciding how much effort to spend, because the answer is usually larger than it feels.

The direct cost is wall-clock time on every pipeline run. Extraction over a large workspace is typically tens of seconds, compilation similar, and the gates add more; on a repository with a hundred pull requests a week that is hours of compute weekly, repeated for every push to every branch.

The indirect cost is worse and less visible. A pipeline that is slow because of tasks nobody changed teaches the team that the pipeline is slow, and the response is usually to run less of it — path filters that skip the gates, a nightly job instead of a per-pull-request one, or a habit of merging while checks are still running. Every one of those decisions is reasonable given a slow pipeline and harmful given a fast one.

There is also a correctness cost specific to localization. When extraction re-runs unnecessarily it rewrites the source catalogue, which can produce a diff — reordered keys, changed formatting — on a branch that touched no strings. Those diffs are noise in review, and noise in a catalogue diff is exactly what makes reviewers stop reading the ones that matter.

Fixing the inputs typically takes an afternoon and is permanent, which is an unusually good ratio. The reason it does not get done is that a cache miss is invisible: nothing fails, the build simply takes longer than it needed to, and no report says so unless someone builds one.

Verification

# Two consecutive runs with no changes: the second must be entirely cached
pnpm turbo run i18n:extract i18n:compile build
pnpm turbo run i18n:extract i18n:compile build --dry-run=json \
  | jq -r '.tasks[] | "\(.taskId) \(.cache.status)"'

# Expected — every line HIT
#   @acme/i18n-tooling#i18n:extract HIT
#   @acme/i18n-messages#i18n:compile HIT
#   @acme/web#build HIT

Make that a CI step on its own. A “cache health” job that runs the build twice and fails when the second run misses catches a regression the day it is introduced, rather than three months later when someone notices builds got slower.

When to escalate

If tasks hit locally and miss in CI, the difference is usually environment rather than inputs: a different Node version, a different lockfile resolution, or environment variables included in the hash. Most runners let you list which variables participate, and an unlisted variable that differs between machines invalidates everything.

If remote caching misses while local caching hits, check that the artifact is actually being uploaded — a task with no declared outputs produces nothing to share, which looks identical to a miss from the consumer’s side.

If everything hits and builds are still slow, the problem has moved from caching to task granularity. One extraction task over a large workspace is a long serial step; splitting it per package lets the runner parallelise, which is the packaging benefit described in monorepo i18n package architecture.

FAQ

Should target catalogues be committed to the repository?

Yes, in most setups — they are the translated content, they need review, and committing them is what lets the compile task be cacheable against a known input. What should not be committed is anything derived from them, because a committed derived artifact is a second source of truth that will drift.

Why does a translation sync invalidate the application build?

Because it should. New translations change the compiled catalogue, which changes what the application ships. What should not be invalidated is extraction, since it never reads target catalogues — and if it is, the input globs are too wide.

Can extraction be skipped when no source files changed?

That is exactly what a correct input declaration achieves. Extraction’s inputs are source files and its configuration; if neither changed, the previous result is valid and the cache restores it. Skipping it manually with a conditional is the same optimisation done less reliably.

Does this apply to Nx as well as Turborepo?

Yes. The vocabulary differs — named inputs, namedInputs and outputs in the project graph — but the failure mode and the fix are identical: declare what the task reads, declare what it writes, and keep generated files out of both by accident.

Should the pseudo-locale bundle be cached too?

Yes, and it is a good test case for the input rules: the transform reads the source catalogue and its own configuration and nothing else, so its hash should repeat whenever neither has changed. If it does not, the transform is non-deterministic — usually because the padding uses randomness or the key order is not stable — and that is worth fixing regardless of caching, since a bundle that differs between runs makes every downstream visual test flaky.

Can a task be excluded from caching entirely?

It can, and occasionally it should: a task with side effects outside the filesystem, such as pushing to a translation service, must not be cached, because a cache hit would skip the side effect. Mark those explicitly rather than letting them miss by accident — an intentionally uncached task and a misconfigured one look identical in a build log, and only one of them is a bug.

Part of Monorepo i18n Package Architecture.