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:

  1. Alphabetical parameter sorting using remeda.sortBy (line 2)
  2. JSON serialization of the sorted object
  3. 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 null if the object doesn't exist
  • Returns null if customMetadata.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) with contentType: "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:

These implementations demonstrate the separation between data fetching and caching logic that the R2 caching layer enables.

Summary

  • Deterministic keys via buildCacheKey with SHA-256 hashing and alphabetical parameter sorting
  • Soft TTL handling through customMetadata.expiresAt checked at retrieval time
  • Explicit storage with content-type headers and caller-supplied expiry in setCached
  • Namespace isolation via CACHE_PREFIX to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →