OpenSEO R2 Caching Mechanism: How Cloudflare R2 Powers API Response Caching
OpenSEO uses a lightweight, type-safe caching layer built on Cloudflare R2 to store transient API data with deterministic keys, soft TTL metadata, and automatic expiration handling.
The R2 caching mechanism in the every-app/open-seo repository eliminates redundant calls to external services like DataForSEO. Located at src/server/lib/r2-cache.ts, this library provides deterministic key generation, retrieval with expiry checks, and storage with custom metadata — all designed for server-side workflows in edge environments.
Core Components of the R2 Caching System
The caching layer consists of three primary functions and supporting constants. Each handles a distinct phase of the cache lifecycle.
Cache Key Construction with buildCacheKey
Deterministic keys prevent cache duplication and ensure identical parameters always map to the same storage location.
The buildCacheKey function (lines 18–27) implements this through:
- Alphabetical parameter sorting using
remeda.sortBy(line 2) - JSON serialization of the sorted object
- SHA-256 hashing via
sha256Hex(lines 66–76) for runtime-stable digests
All keys are prefixed with "dataforseo-cache/" via the CACHE_PREFIX constant (line 12), preventing collisions with other bucket data.
import { buildCacheKey } from "@/server/lib/r2-cache";
// Generates: "dataforseo-cache/research-a3f7b2..."
const key = await buildCacheKey("research", {
keyword: "seo tools",
location: "United States",
// Parameter order doesn't matter — sorting ensures consistency
});
Retrieval with getCached and Expiry Validation
The getCached function (lines 30–45) implements soft TTL semantics. It checks both existence and freshness:
- Returns
nullif the object doesn't exist - Returns
nullifcustomMetadata.expiresAt(line 38) is in the past - Otherwise parses and returns the stored JSON (line 42)
This design lets callers fall back to live API calls transparently.
import { getCached } from "@/server/lib/r2-cache";
const cached = await getCached("dataforseo-cache/research-a3f7b2...");
if (cached) {
return cached; // Cache hit within TTL window
}
// Cache miss or expired — proceed to API call
Storage with setCached and Custom Metadata
The setCached function (lines 48–62) persists JSON payloads with explicit content-type headers and expiry metadata:
- Writes via
env.R2.put(line 56) withcontentType: "application/json" - Attaches
customMetadata.expiresAt(line 59) calculated from caller-supplied TTL
import { setCached, CACHE_TTL } from "@/server/lib/r2-cache";
await setCached(
"dataforseo-cache/research-a3f7b2...",
freshApiData,
CACHE_TTL.researchResult // 86,400 seconds (24 hours)
);
TTL Management with CACHE_TTL
TTL constants are centralized for maintainability. The current CACHE_TTL object (lines 5–10) defines:
| Constant | Value | Use Case |
|---|---|---|
researchResult |
86,400 seconds (24 hours) | Keyword research data from DataForSEO |
Callers supply numeric TTL values directly, enabling per-endpoint flexibility without modifying the core library.
Complete Working Example
This pattern demonstrates the full cache lifecycle as used in production workflows:
import {
buildCacheKey,
getCached,
setCached,
CACHE_TTL,
} from "@/server/lib/r2-cache";
async function fetchResearch(params: Record<string, unknown>) {
// 1. Build deterministic cache key
const cacheKey = await buildCacheKey("research", params);
// 2. Attempt cache retrieval
const cached = await getCached(cacheKey);
if (cached) {
console.log("Cache hit — skipping API call");
return cached;
}
// 3. Cache miss — fetch from external service
console.log("Cache miss — calling DataForSEO");
const freshData = await callDataForSeo(params);
// 4. Store with 24-hour TTL
await setCached(cacheKey, freshData, CACHE_TTL.researchResult);
return freshData;
}
Production Usage Across the Codebase
The R2 caching mechanism appears in multiple services with varying TTL strategies:
src/server/features/ai-search/services/promptExplorer.ts— Caches prompt-model tuples for 7 dayssrc/server/features/ai-search/services/brandLookup.ts— Stateless UI rendering with R2-only cachingsrc/server/functions/lighthouse.ts— Uses lower-levelgetJsonFromR2/putTextToR2fromsrc/server/lib/r2.tsfor Lighthouse payloads
These implementations demonstrate the separation between data fetching and caching logic that the R2 caching layer enables.
Summary
- Deterministic keys via
buildCacheKeywith SHA-256 hashing and alphabetical parameter sorting - Soft TTL handling through
customMetadata.expiresAtchecked at retrieval time - Explicit storage with
content-typeheaders and caller-supplied expiry insetCached - Namespace isolation via
CACHE_PREFIXto prevent bucket collisions - Type-safe, portable design suitable for any server-side workflow in edge environments
Frequently Asked Questions
What makes OpenSEO's R2 caching "type-safe"?
The src/server/lib/r2-cache.ts module exports strictly typed functions with TypeScript generics. The getCached function returns Promise<T | null>, and setCached accepts Record<string, unknown> for keys with enforced TTL numbers. This prevents runtime errors from malformed cache entries or incorrect metadata.
Why use SHA-256 for cache keys instead of plain JSON strings?
SHA-256 hashing in sha256Hex (lines 66–76) produces fixed-length, URL-safe keys regardless of parameter complexity. As implemented in open-seo, this avoids R2 key length limitations and ensures consistent key generation across different runtime environments where JSON stringification might vary.
How does the soft TTL differ from R2's native expiration?
The soft TTL uses customMetadata.expiresAt checked at read time in getCached (line 38), whereas native R2 lifecycle rules operate at the bucket level. This application-controlled approach lets OpenSEO extend or shorten effective cache lifetimes without reconfiguring bucket policies, and enables immediate fallback to live APIs on expiration.
Can the R2 caching mechanism handle non-JSON data?
The core src/server/lib/r2-cache.ts utilities are JSON-specific. For raw text or binary data, src/server/lib/r2.ts provides lower-level getTextFromR2, putTextToR2, and getJsonFromR2 functions. The lighthouse.ts function demonstrates this pattern for Lighthouse audit payloads.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →