Solving Cloudflare R2 Browser Disk Cache Issues with Workers
The Problem
A developer was experiencing frustrating cache behavior with their Cloudflare Worker serving CSV files from R2 storage. Despite setting a 1-year TTL on their R2 bucket, a 30MB CSV file wasn't hitting the browser's disk cache when tested with Chrome DevTools' cache enabled. The expectation was that with such a long TTL, the file should be cached locally for offline access.
This is actually an extremely common pitfall in the Cloudflare + R2 + Worker ecosystem, and the behavior they observed was completely normal - though not what they expected.
Investigation: Understanding the Cache Layers
The Critical Misconception
The first and most important thing to understand is that R2's TTL ≠ browser disk caching. This confusion accounts for about 90% of similar issues.
What R2's TTL actually controls:
- Whether Cloudflare's edge network retains the object
- Internal CDN cache behavior
- Not browser caching behavior
What controls browser disk caching:
- HTTP response headers in the actual response
- Specifically:
Cache-Control,Expires,ETag,Content-Length - Response characteristics (streaming vs. complete)
Browser Cache Decision Making
Browsers only care about HTTP response headers when deciding whether to write to disk cache. The key headers that influence this decision are:
Cache-Control: public, max-age=31536000
Content-Length: 31457280
Accept-Ranges: bytes
ETag: "abc123def456"If these headers are missing or incorrect, even a 100GB file with a 100-year R2 TTL will either:
- Only go to memory cache
- Not be cached at all
Root Cause Analysis
Issue 1: Default Worker Response Behavior
When you create a Worker response like this:
return new Response(object.body)The browser receives:
Cache-Control: no-store (or missing entirely)This immediately disqualifies the response from disk caching.
Issue 2: Streaming Responses vs. Disk Cache
The second major issue is that object.body from R2 is a ReadableStream. Chrome has a well-documented behavior where streaming responses are much less likely to be written to disk cache, especially for large files.
Why streaming responses avoid disk cache:
- Browser can't verify integrity before the stream completes
- Uncertain content length makes cache indexing risky
- Chrome errs on the side of caution
Issue 3: Missing Content-Length Header
Without an explicit Content-Length header, Chrome becomes extremely reluctant to write large files to disk cache. This header helps the browser:
- Allocate appropriate disk space
- Validate complete downloads
- Index the cache entry properly
The Solution
Looking at the developer's existing Worker code, it was already quite sophisticated with proper CORS handling and Range request support. The fix required minimal changes while preserving all existing functionality.
Step 1: Add Browser Cache Headers
The first modification was to add explicit browser caching headers right after the R2 object metadata is written:
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set("etag", object.httpEtag)
// ✅ Add browser caching (separate from R2 TTL)
headers.set(
"Cache-Control",
"public, max-age=31536000, immutable"
)Step 2: Handle Range vs. Non-Range Responses Differently
The key insight was to treat Range requests and full file requests differently:
- Range requests: Keep streaming (required for proper Range behavior)
- Full file requests: Use complete
arrayBuffer()response
Step 3: Complete Solution Implementation
Here's the modified approach for the non-Range response path:
// --- Non-Range: Force complete response for disk cache ---
let body = null
if (hasBody) {
// ❗️Critical: Don't use stream for full responses
body = await object.arrayBuffer()
headers.set("Content-Length", String(body.byteLength))
headers.set("Accept-Ranges", "bytes")
}
// Add CORS headers
for (const [k, v] of Object.entries(cors)) headers.set(k, v)
return new Response(body, {
status: hasBody ? 200 : 412,
headers,
})Why This Solution Works
This approach satisfies all of Chrome's requirements for disk caching:
| Requirement | Status |
|---|---|
| Non-streaming response | ✅ |
| Content-Length header | ✅ |
| Cache-Control: public | ✅ |
| Proper ETag | ✅ |
| Accept-Ranges | ✅ |
| No cache-busting headers | ✅ |
Additional Debugging Considerations
DevTools Testing Gotcha
A critical point for testing: Chrome's caching behavior changes when DevTools is open. Even with "Disable cache" unchecked, Chrome may still avoid writing certain resources to disk cache while DevTools is active.
Proper testing procedure:
- Close DevTools completely
- Navigate to the resource URL
- Reopen DevTools
- Check the Network tab for cache status
Range Request Caching
The solution also adds caching headers to Range responses (206 status), which many developers forget:
headers.set("Content-Range", `bytes ${start}-${end}/${size}`)
headers.set("Content-Length", String(end - start + 1))
headers.set("Cache-Control", "public, max-age=31536000, immutable") // Don't forget this!Range responses can and should be cached by browsers, especially for large media files or datasets.
Performance Reality Check
While solving the disk cache issue is satisfying from a technical standpoint, it's important to maintain perspective on the actual performance impact:
Cloudflare Edge cache hit + Browser memory cache = 99% of the performance benefit
For a 30MB CSV file, the difference between memory cache and disk cache is often imperceptible to users. The primary benefits of disk cache are:
- Offline access capability
- Survival across browser memory pressure
- Faster cold starts after browser restarts
Lessons Learned
1. Understand Cache Layer Separation
R2 TTL, Cloudflare Edge caching, and browser caching are three completely separate systems. Configuration in one layer doesn't automatically propagate to others.
2. Streaming vs. Complete Responses
For large files where disk caching is important, consider the trade-offs:
- Streaming: Better memory usage, faster time-to-first-byte
- Complete: Better caching behavior, requires more memory
3. Test Cache Behavior Properly
DevTools can lie about caching behavior. Always test with DevTools closed for definitive results.
4. Headers Are Everything
When debugging cache issues, start with the response headers. They're the single source of truth for browser caching decisions.
Prevention Tips for Future Development
- Always set explicit Cache-Control headers in your Worker responses
- Include Content-Length for cacheable resources larger than a few MB
- Test caching behavior in multiple scenarios: DevTools open/closed, incognito mode, different browsers
- Monitor your cache hit rates at both the CDN and browser level
- Document your caching strategy for each resource type
The solution transformed a frustrating mystery into a well-understood caching architecture, ensuring that large CSV files would reliably cache to disk while maintaining all the sophisticated CORS and Range request handling that was already working perfectly.
