A deno.json task with a files list now remembers whether it needs to run at all. I hadn't seen this mentioned anywhere, so I built a small, deliberately expensive build step, ran it with and without the new caching fields, and tried to break it.
The short version: a task that took 3.9 to 4.3 seconds dropped to under 10 milliseconds once nothing in its declared inputs had changed. That part works as advertised. What doesn't work the way I expected is the cost of checking in the first place, and what happens when a task writes to a file it also lists as an input.
What the feature actually is
Deno's task runner (deno task, driven by the tasks block in deno.json) now accepts three extra fields on a task definition:
{
"tasks": {
"build": {
"command": "deno run --allow-read --allow-write build.ts",
"files": ["src/**/*.txt", "build.ts"],
"output": ["dist/**"]
}
}
}
-
filesis the input glob. Declaring it is what turns caching on at all; a task with nofilesfield always runs. -
outputis the glob of files the task produces. On a cache hit, these get restored from the cache store rather than regenerated. -
env(not shown above) lists environment variable names whose values also feed the fingerprint.
I ran this against Deno 2.9.8, installed from the official Linux x86_64 build, with no --unstable flag anywhere. This is shipped, stable behaviour, not a preview.
The headline number
My build.ts reads every file under src/, hashes each one 2000 times with SHA-256 to simulate real CPU-bound work, and writes a combined manifest to dist/. Forty source files, about 1.1MB total.
| Run | Cold (cache cleared first) | Warm (cache hit) |
|---|---|---|
| 1 | 4.289s | 0.0100s |
| 2 | 4.025s | 0.0093s |
| 3 | 3.939s | 0.0100s |
Fastest cold run against fastest warm run: 3.939s versus 0.0093s, a 423x difference. All three warm runs printed the same line:
Task build deno run --allow-read --allow-write build.ts (cached, inputs unchanged)
No process spawn for deno run, no re-read of the source files, no re-hashing. The task is simply not executed.
It really is content, not timestamps
The cache manifest, which lives under ~/.cache/deno/task_cache_v1/<hash>/manifest.json, records a size and an mtime for every tracked file alongside a content_hash and a static_hash. Seeing mtime sitting right there in the JSON, I assumed it was part of what decided a cache hit, the way plenty of build tools work.
I ran touch src/file_5.txt with no edit to the file's contents, then ran the task again:
Task build deno run --allow-read --allow-write build.ts (cached, inputs unchanged)
Still cached. Then I actually appended a line to the same file and ran it again:
Task build deno run --allow-read --allow-write build.ts
build.ts actually ran
That re-ran, and the following run went back to cached. So the documentation's claim ("the contents of the matching files") holds up under a direct test: mtime and size are a cheap pre-check to decide whether a file is worth reading, not the thing that decides the final verdict. A bare timestamp bump doesn't invalidate anything.
Output really does get restored, not just skipped
The other claim worth checking: that deleting your output directory and re-running a cached task regenerates it. I deleted dist/ entirely, ran the cached task again, and dist/main.txt came back with the exact content the last real build produced, while build.log (a side effect outside the declared output glob, used only to prove whether the command itself re-ran) stayed untouched. The command did not execute. The files were pulled back from the cache store. Both halves of the claim check out.
Where it quietly never helps you
I wrote a task whose files glob included a file that the command itself appends to:
{
"tasks": {
"selfmutate": {
"command": "echo appended >> src/input.txt && echo ran",
"files": ["src/*.txt"]
}
}
}
Run it three times and src/input.txt ends with three "appended" lines. Every run changes the very input it's fingerprinted against, so every run invalidates its own next run. Nothing in the tool warns you about this. If your build step writes a log, a timestamp, or a generated file into the same directory it reads from, caching silently does nothing for you, and you'd have no way to tell short of watching the task actually execute every time.
Two things that used to be bugs and aren't anymore
Deno's own commit history shows a real defect here: for a while, deno task build foo and deno task build bar produced the same fingerprint, because the hash only covered the command string, not the arguments appended after it. I tested this directly on 2.9.8 with a task that just echoes its arguments:
$ deno task argtask -- foo # first run: executes
$ deno task argtask -- foo # second run: cached
$ deno task argtask -- bar # different arg: executes again, correctly
Different arguments get different fingerprints now. Same story for env: a task with "env": ["MODE"] ran fresh under MODE=dev, cached on the repeat, then ran fresh again the moment I switched to MODE=prod, then cached again on the repeat of that. Both the documented behaviour and a previously real bug check out against the current release.
What it costs when the task itself is cheap
This is the finding that argues against the headline number. I built a 3000-file, 12MB input tree and attached it to a task that does nothing but echo noop:
with files tracking (cache hit) |
no files field at all |
|
|---|---|---|
| Run 1 | 0.0320s | 0.0101s |
| Run 2 | 0.0314s | 0.0080s |
| Run 3 | 0.0346s | 0.0106s |
Checking whether 3000 files changed cost roughly 3 to 4 times longer than just running the command with no tracking at all, even on a hit. For my 4-second hashing job this overhead is noise. For a task that's already fast, say a lint pass over a small package that finishes in 20ms, attaching files over a large tree could plausibly cost more than it saves. Deno checks file size and modification time before reading contents, which keeps the common case cheap, but on a big enough tree that pre-check alone adds up.
No concurrency protection, and no partial reset
I started the same cold build task from two processes at once, with the cache cleared beforehand:
A: ELAPSED 3.9316s (actually ran)
B: ELAPSED 4.0020s (actually ran)
Both ran the full 4-second task in parallel. Nothing serialised them, and nothing let the second one wait for the first and reuse its result. If two CI steps or two editor saves trigger the same expensive task at once, you pay for it twice, not once.
There's also no way to invalidate a single task's cache entry. deno task has no --no-cache flag. The only lever is deno clean, which removes the entire DENO_DIR, not just task_cache_v1:
$ deno clean
Removed /root/.cache/deno (24 files, 607.61KB)
That also wipes the module cache, the npm cache, and everything else Deno stores, which on a real project is a much bigger rebuild than just re-running one build step.
What I got wrong on the way
My first assumption, reading the cache manifest before running a single test, was that the stored mtime field was load-bearing: that touching a file would be enough to force a rebuild, because that's how make and a lot of task runners behave. It isn't. The manifest stores mtime as a cheap filter to decide whether to bother reading a file at all; the actual cache decision rests on the content hash underneath it. I only caught this because I tested the boring case (touch, no edit) before the interesting one (real edit), rather than assuming they'd behave the same.
Run it yourself
This needs only the Deno binary, no Docker and no account:
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
deno --version # I used 2.9.8
mkdir deno-cache-test && cd deno-cache-test
mkdir src
for i in $(seq 1 40); do head -c 20000 </dev/urandom | base64 > "src/file_$i.txt"; done
cat > build.ts <<'EOF'
const files = [];
for (const entry of Deno.readDirSync("src")) if (entry.isFile) files.push(`src/${entry.name}`);
files.sort();
let combined = "";
for (const f of files) {
let digest = await Deno.readFile(f);
for (let i = 0; i < 2000; i++) {
digest = new Uint8Array(await crypto.subtle.digest("SHA-256", digest));
}
combined += `${f} ${Array.from(digest).map(b => b.toString(16).padStart(2,"0")).join("")}\n`;
}
await Deno.mkdir("dist", { recursive: true });
await Deno.writeTextFile("dist/manifest.txt", combined);
console.error("build.ts actually ran");
EOF
cat > deno.json <<'EOF'
{
"tasks": {
"build": {
"command": "deno run --allow-read --allow-write build.ts",
"files": ["src/**/*.txt", "build.ts"],
"output": ["dist/**"]
}
}
}
EOF
time deno task build # cold: several seconds
time deno task build # warm: single-digit milliseconds
If you want to see the self-mutation pitfall, add a task whose command appends to a file matched by its own files glob, and watch it execute on every run regardless.
What to do with this
Point files only at what the specific task actually reads, never at src/** for a whole repo when the task touches one package, and check first that the command doesn't also write into that same glob, or caching will quietly do nothing for it. And before adding this to something that already runs in under 50ms, time it cold and warm the way I did above. On my 3000-file tree, the fingerprint check alone cost more than that.
Top comments (2)
That 3,000-file case is a great caveat because cache lookup is itself work. The best cache metric is total wall time across cold, warm, and invalidation paths; a fast hit that makes the common miss slower can be a worse developer experience than no cache.
yep. and the 3000-file case was the friendly version, that was a hit on a task that does nothing, and the check alone still cost 3 to 4x the untracked run. a miss pays that same check and then runs the task anyway, so for anything already fast you're just adding time. I didn't time the miss path on the big tree though, that'd be the number to add.