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.
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.
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.
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.
Related
- Monorepo i18n Package Architecture — the package boundaries these task inputs follow.
- Extracting translation keys with i18next-parser — the extraction step being cached.
- GitHub Actions i18n CI Gates — the gates that run downstream of these tasks.
- String Catalog Governance — why catalogues are generated rather than hand-edited.
Part of Monorepo i18n Package Architecture.