Running several apps as an indie developer means periodically re-reading the same pages: dependency release notes, store policy documents, changelogs. I hand most of that reading to Claude Code. One afternoon I was scrolling through a local proxy log and noticed the same release-notes page being pulled several times inside a single session.
In hindsight it is obvious. When you compare several pages against each other, you keep returning to whichever one you treat as the baseline. Humans do it. Agents do it too.
Claude Code v2.1.233 added CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS, which controls how long a fetched URL stays reusable. The documented default is 15 minutes. Whether you should touch that number depends entirely on the shape of your own research — so let us look at that shape first, then decide.
Start by watching how often you hit the same URL
Counting repeat requests against a real website is awkward. It is much faster to stand up a small local server and point the work at that instead.
# server.py — an observation server that does nothing but count hits
import http.server, socketserver, datetime, collections
HITS = collections.Counter()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
HITS[self.path] += 1
ts = datetime.datetime.now().strftime("%H:%M:%S")
print(f"{ts} GET {self.path} (hit #{HITS[self.path]})", flush=True)
body = f"doc for {self.path}\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass # silence the default access log so counts stay readable
with socketserver.TCPServer(("127.0.0.1", 8731), Handler) as httpd:
print("listening on http://127.0.0.1:8731", flush=True)
httpd.serve_forever()Start it with python3 server.py, request the same path three times, and you get:
15:06:33 GET /release-notes (hit #1)
15:06:33 GET /release-notes (hit #2)
15:06:33 GET /release-notes (hit #3)
15:06:33 GET /policy (hit #1)
Two small details matter here. flush=True makes each line appear the instant the request lands, and silencing log_message keeps the built-in access log from doubling every entry. Skip either one and the counts become hard to read at exactly the moment you need them to be clear.
From there, give Claude Code a research prompt that includes local URLs such as http://127.0.0.1:8731/release-notes and watch how the hit counters climb. If a path keeps incrementing, your work has a refetch-heavy shape and the TTL is worth tuning. If it does not, changing the setting will buy you very little.
How the cache is scoped — whether it is shared across sessions or processes — is the kind of thing that varies by environment. Rather than assuming, use this same server to observe what your setup actually does.
Fetch count and freshness move in opposite directions
Once you know the shape, you can estimate what different TTLs would do. Re-running full sessions repeatedly is slow, so it is quicker to feed an access trace into a short script.
# ttl_model.py — turn an access trace into fetch counts and data age
import random
def build_trace(minutes=90, seed=7):
"""Return often to a few baseline pages, occasionally open a new link."""
rnd = random.Random(seed)
core = [f"https://example.test/core/{i}" for i in range(4)]
trace = []
for t in range(minutes):
for _ in range(rnd.choice([0, 1, 1, 2])):
if rnd.random() < 0.62:
trace.append((t, rnd.choice(core)))
else:
trace.append((t, f"https://example.test/leaf/{rnd.randrange(400)}"))
return trace
def count_fetches(trace, ttl_min):
last, fetches = {}, 0
for t, url in trace:
prev = last.get(url)
if prev is None or t - prev >= ttl_min:
fetches += 1
last[url] = t
return fetches
def staleness(trace, ttl_min):
"""How old the content in hand is at each point of access."""
last, ages = {}, []
for t, url in trace:
prev = last.get(url)
if prev is None or t - prev >= ttl_min:
last[url] = t
ages.append(0)
else:
ages.append(t - prev)
return max(ages), sum(ages) / len(ages)
trace = build_trace()
for ttl in (0, 5, 15, 60, 240):
mx, av = staleness(trace, ttl)
print(f"TTL={ttl:>3}m fetches={count_fetches(trace, ttl):>3} "
f"worst age={mx:>2}m mean age={av:>4.1f}m")Here is what that produced on my machine, for a trace of 86 accesses across 30 unique URLs standing in for a 90-minute research session.
| TTL | Actual fetches | Refetches avoided | Worst age of data in hand | Mean age |
|---|---|---|---|---|
| 0 min (always fetch) | 86 | 0.0% | 0 min | 0.0 min |
| 5 min | 64 | 25.6% | 4 min | 0.6 min |
| 15 min (default) | 45 | 47.7% | 14 min | 3.8 min |
| 60 min | 34 | 60.5% | 59 min | 14.4 min |
| 240 min | 30 | 65.1% | 85 min | 24.3 min |
These figures belong to this particular input, not to your work. Replace build_trace with a trace derived from your own logs and read the slope of your own curve rather than borrowing mine.
The direction of that slope is still telling. Going from 15 minutes to 60 removes only another 13% of fetches, while the mean age of the content in hand grows from 3.8 minutes to 14.4 — roughly four times older. Nearly all the benefit lives between 0 and 15 minutes; past that point you are trading freshness away for very little. The 15-minute default sits just before the bend.
Decide per use case which side you care about
Which side of that trade you favor depends on how fast the thing you are reading changes. Across the three kinds of lookups I do most often, it breaks down like this.
| Kind of lookup | How fast it changes | Lean toward | Why |
|---|---|---|---|
| Tracking release notes and changelogs | Days | Default, or shorter | Updates rarely land mid-session, but reaching a conclusion from an outdated line is expensive to undo |
| Reading through terms and policies | Months | Longer | You return to the same clauses repeatedly, and mid-session changes are not a realistic concern |
| Verifying a page you just published | You change it yourself | Short, or off | You are fetching something you just edited, so a cached copy defeats the entire purpose |
That third row is where I lost the most time. I was asking for a check on a page I had just fixed, and the answer came back clean — based on the version from before the fix. I spent a while doubting the fix itself before suspecting my own configuration. A cache is simply a liar about anything you are actively rewriting.
Apply the setting, then confirm it landed
The value is in milliseconds. One hour looks like this:
# scoped to a single invocation
CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS=3600000 claude
# for verification work, where caching gets in the way
CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS=0 claudeYou can export it from your shell profile to make it permanent, but I settled on prefixing it per invocation instead. Research and post-publish verification want opposite settings, so pinning one value guarantees friction in the other case.
After setting it, go back to the observation server and check that it took effect. Run the same research prompt before and after the change; if the hit counters climb differently, the setting is live. If nothing changes, either the value is malformed or it is not reaching the process you think it is.
For the wider picture, see Claude Code Environment Variables — The Complete Practical Reference. The Bash tool memory limit that shipped in the same v2.1.233 is covered in A Runaway Build Dies Very Differently Under cgroup Than Under ulimit.
What to do next
Start the observation server and run one of your usual research prompts through it unchanged. If any path climbs into double-digit hits, a longer TTL will pay for itself. If nothing climbs, leave the default alone. Looking at the shape of your own work before picking a number takes ten minutes and removes most of the guesswork.
It is one environment variable, but putting numbers around it makes the trade visible: exactly what you are giving up, and exactly what you are getting for it.