Caching is the single most impactful performance optimization for SaaS applications — it reduces latency, lowers origin load, and improves user experience. Cloudflare provides a multi-layered caching stack (CDN, Workers Cache API, KV, D1 query caching, Cache Reserve) that can serve content from edge locations within 30ms globally. This guide covers caching strategies at each layer, with practical patterns used at tanstackship.com.
The Caching Stack
Cloudflare's caching infrastructure has multiple layers, each with different characteristics:
| Layer | Latency | Storage | Data Freshness | Use Case |
|---|---|---|---|---|
| CDN Cache | ~5ms (cache hit) | Configurable | TTL-based | Static assets, HTML |
| Workers Cache API | ~1ms (in-memory) | 128MB per Worker | Manual | API responses, dynamic content |
| Workers KV | ~10ms (global) | Unlimited | Eventually consistent | Configuration, feature flags |
| D1 Query Cache | ~2ms (prepared) | Database-level | Per-query | Database query results |
| Cache Reserve | ~10ms | Unlimited persistent | TTL + LRU | Large objects, images |
Layer 1: CDN Caching
Default Edge Caching
// wrangler.jsonc — configure caching rules
{
"name": "tanstack-ship",
"routes": [{ "pattern": "tanstackship.com/*", "zone_id": "YOUR_ZONE" }],
"assets": {
"bucket": "./dist/public",
"browser_ttl": 31536000, // 1 year for assets
"serve_single_page_app": false
}
}
Cache-Control Headers
// Set cache-control headers from server functions
export const getPublicProduct = createServerFn({ method: "GET" }).handler(
async ({ data, context }) => {
const product = await context.env.DB.prepare(
"SELECT * FROM products WHERE id = ? AND published = 1"
).bind(data.id).first()
// Cache public product data for 5 minutes on the edge
context.response.headers.set("Cache-Control", "public, s-maxage=300, max-age=60")
return product
}
)
| Cache Directive | Purpose | Value |
|---|---|---|
s-maxage |
Edge cache TTL (CDN) | 300 (5 minutes) |
max-age |
Browser cache TTL | 60 (1 minute) |
stale-while-revalidate |
Serve stale while fetching fresh | 86400 (1 day) |
stale-if-error |
Serve stale if origin errors | 604800 (1 week) |
Layer 2: Workers Cache API
For dynamic content that cannot be CDN-cached but benefits from in-memory caching:
// server/middleware/cache.ts
interface CacheOptions {
ttl: number // Time to live in seconds
key: string // Custom cache key
}
export async function withWorkerCache<T>(
handler: () => Promise<T>,
options: CacheOptions,
request: Request
): Promise<T> {
const cacheKey = new Request(`https://cache.tanstackship.com/${options.key}`, request)
const cache = caches.default
// Try cache first
const cachedResponse = await cache.match(cacheKey)
if (cachedResponse) {
const data = await cachedResponse.json()
console.log(`Cache HIT for ${options.key}`)
return data as T
}
console.log(`Cache MISS for ${options.key}`)
// Execute the handler
const data = await handler()
// Store in cache
const response = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${options.ttl}`,
},
})
// Wait for cache storage (don't block the response)
request.waitUntil?.(cache.put(cacheKey, response))
return data
}
Usage in Server Functions
// Cache frequently accessed data
export const getDashboardStats = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
return withWorkerCache(
async () => {
// Expensive query — only runs on cache miss
const [users, revenue, subscriptions] = await Promise.all([
context.env.DB.prepare("SELECT COUNT(*) as count FROM users").first(),
context.env.DB.prepare("SELECT SUM(mrr) as total FROM subscriptions WHERE status = 'active'").first(),
context.env.DB.prepare("SELECT status, COUNT(*) as count FROM subscriptions GROUP BY status").all(),
])
return { users, revenue, subscriptions: subscriptions.results }
},
{ ttl: 300, key: "dashboard/stats" },
context.request
)
}
)
Layer 3: Workers KV for Global Configuration
KV is ideal for configuration data that changes infrequently:
// Store pricing configuration in KV
await context.env.KV.put("pricing:starter", JSON.stringify({
monthlyPrice: 29,
features: ["basic_analytics", "email_support"],
}))
// Read from any Worker globally with ~10ms latency
const pricing = JSON.parse(
await context.env.KV.get("pricing:starter") ?? "{}"
)
When to Use KV vs D1 vs Cache API
| Data Type | Storage | Read Latency | Consistency | TTL |
|---|---|---|---|---|
| Session tokens | KV | ~10ms | Eventually consistent | Session duration |
| Pricing config | KV | ~10ms | Eventually consistent | Manual update |
| Feature flags | KV | ~10ms | Eventually consistent | 30s-5min cache |
| Blog content | D1 | ~5ms | Strong | N/A |
| User dashboard | Cache API | ~1ms | Strong | 1-5min |
| Static assets | CDN | ~5ms | Strong | 1 year |
Layer 4: Stale-While-Revalidate
The most important caching pattern for SaaS:
export const getAnalyticsData = createServerFn({ method: "GET" }).handler(
async ({ data, context }: { data: { timeframe: string } }) => {
// Serve from cache, then refresh in background
context.response.headers.set(
"Cache-Control",
"public, s-maxage=60, stale-while-revalidate=86400, stale-if-error=604800"
)
// User sees cached data instantly
// Worker refreshes cache in background
const result = await context.env.DB.prepare(`
SELECT date, pageviews, unique_visitors
FROM analytics_summary
WHERE date >= datetime('now', ?)
`).bind(
data.timeframe === "7d" ? "-7 days" : "-30 days"
).all()
return result.results
}
)
Cache Invalidation
Manual Cache Purging
// server/admin/cache.ts
export const purgePageCache = createServerFn({ method: "POST" }).handler(
async ({ data, context }: { data: { url: string } }) => {
// Purge specific URL from CDN cache
const formData = new FormData()
formData.append("files", data.url)
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${context.env.CLOUDFLARE_ZONE}/purge_cache`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${context.env.CLOUDFLARE_API_TOKEN}`,
},
body: JSON.stringify({ files: [data.url] }),
}
)
return response.ok ? { purged: true } : { purged: false }
}
)
Cache Tags (Surrogate Keys)
// Tag cache entries for granular invalidation
context.response.headers.set(
"Cache-Tag",
`user-${userId},org-${orgId},page-dashboard`
)
// Purge all cache entries with a specific tag
// POST /api/purge?tags=user-123
Cache Strategy Decision Tree
Is the data user-specific?
├── YES → Do not cache at CDN level
│ ├── Use Workers Cache API with user-specific key
│ └── TTL: 30s-5min depending on staleness tolerance
└── NO → Can benefit from CDN cache
├── How often does it change?
│ ├── Never (static assets) → CDN cache 1 year
│ ├── Rarely (pricing, config) → CDN cache 1 hour + purge on update
│ └── Frequently (dashboard data) → CDN cache 1-5min
└── Is stale data acceptable?
├── YES → stale-while-revalidate for best performance
└── NO → Short TTL with Workers Cache API fallback
Cache Hit Rate Optimization
// Log and monitor cache performance
export const getCacheMetrics = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const metrics = await context.env.ANALYTICS.query({
sql: `
SELECT
blob1 as cache_status,
count() as count,
avg(double1) as avg_latency
FROM analytics_cache
WHERE timestamp > now() - INTERVAL '1' HOUR
GROUP BY blob1
`,
})
return metrics.rows
}
)
| Cache Status | Meaning | Target % | Action If Below Target |
|---|---|---|---|
| HIT | Served from edge cache | > 70% | Increase TTL, add more cacheable endpoints |
| MISS | Not in cache, fetched from origin | < 20% | Review cache key, pre-warm cache |
| STALE | Served stale, refreshing in bg | < 10% | Increase stale-while-revalidate window |
| DYNAMIC | Bypassed by explicit no-cache | < 5% | Audit no-cache declarations |
Production Caching Checklist
- [ ] Static assets cached for 1 year with content hash in filename
- [ ] HTML pages cached with 5-60s TTL + stale-while-revalidate
- [ ] API responses cached with appropriate TTL based on update frequency
- [ ] Workers Cache API used for user-specific but slow-changing data
- [ ] KV used for global configuration and feature flags
- [ ] Cache tags set for granular invalidation
- [ ] Cache hit rate monitored and alerted on drop > 10%
- [ ] Stale-while-revalidate enabled for critical endpoints
- [ ] Cache pre-warming for known traffic patterns
- [ ] Cache key strategy reviewed for query parameter variance
Conclusion
Edge caching is not a single configuration — it is a multi-layered strategy. The right approach depends on the data: static assets should be cached aggressively, user-specific data should use Workers Cache API, configuration should live in KV, and database queries should be cached only where staleness is acceptable.
The Cloudflare stack makes this strategy accessible: CDN cache for the first layer, Cache API for dynamic content, KV for configuration, and Cache Reserve for large objects. Combined, they can serve 90%+ of requests from edge cache with <10ms latency.
For a production SaaS with an optimized caching strategy, see tanstackship.com.
Top comments (0)