# OpenSEO R2 Caching Mechanism: How Cloudflare R2 Powers API Response Caching

> Discover the OpenSEO R2 caching mechanism. Learn how Cloudflare R2 efficiently stores transient API data with soft TTL and automatic expiration for faster responses.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-09

---

**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](https://github.com/every-app/open-seo) repository eliminates redundant calls to external services like DataForSEO. Located at [`src/server/lib/r2-cache.ts`](https://github.com/every-app/open-seo/blob/main/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.

```typescript
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.

```typescript
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

```typescript
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:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/src/server/features/ai-search/services/promptExplorer.ts)** — Caches prompt-model tuples for 7 days
- **[`src/server/features/ai-search/services/brandLookup.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/ai-search/services/brandLookup.ts)** — Stateless UI rendering with R2-only caching
- **[`src/server/functions/lighthouse.ts`](https://github.com/every-app/open-seo/blob/main/src/server/functions/lighthouse.ts)** — Uses lower-level `getJsonFromR2` / `putTextToR2` from [`src/server/lib/r2.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/r2.ts) for Lighthouse payloads

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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/server/lib/r2-cache.ts) utilities are JSON-specific. For raw text or binary data, [`src/server/lib/r2.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/r2.ts) provides lower-level `getTextFromR2`, `putTextToR2`, and `getJsonFromR2` functions. The [`lighthouse.ts`](https://github.com/every-app/open-seo/blob/main/lighthouse.ts) function demonstrates this pattern for Lighthouse audit payloads.