Workers KV Edge Caching Integration

Priority: P0 (Immediate)

What is Workers KV?

Globally distributed, eventually consistent key-value store optimized for \1. Data is cached at 335+ edge locations.

Why This Matters for Company Manager

Current Caching

1. \1: 15-minute in-memory instance cache (\1) -- per-process only 2. \1: Used primarily for Bull queues, not general caching 3. \1: Every TRPC call hits Neon PostgreSQL directly 4. \1: Multiple server instances don't share cache

Cache Opportunity Analysis

DataRead:Write RatioStaleness ToleranceKV Fit
Tenant config1000:15 minExcellent
Site settings500:15 minExcellent
Permission sets200:11 minExcellent
Product catalog100:15 minExcellent
Menu items500:110 minExcellent
Feature flags1000:11 minExcellent
User sessions50:10 (real-time)Good (short TTL)
Inventory levels10:10 (real-time)Poor
Order data5:10 (real-time)Poor

Architecture

Cache-Aside Pattern


Browser → Next.js → TRPC Router → KV.get(key)
                                      │
                                  ┌────┴────┐
                                  │ Hit?    │
                                  │ Yes → return cached
                                  │ No  → DB query → KV.put(key, value, {ttl})
                                  └─────────┘

For Workers


Browser → Worker → KV Binding → KV (edge, <5ms)
                       │
                   miss → Hyperdrive → Neon → cache in KV

Implementation

Step 1: Create KV Namespaces


# Production
npx wrangler kv namespace create CACHE
npx wrangler kv namespace create CACHE --preview  # for dev

# Separate namespace for sessions (different TTL patterns)
npx wrangler kv namespace create SESSIONS

Step 2: KV Cache Service

Create a reusable cache service for the app:


// packages/services/src/cache/kv-cache.ts

interface KVCacheOptions {
  /** TTL in seconds. Default: 300 (5 min) */
  ttl?: number;
  /** Tenant ID for key namespacing */
  tenantId: string;
}

export class KVCacheService {
  private baseUrl: string;
  private accountId: string;
  private apiToken: string;

  constructor(config: { accountId: string; namespaceId: string; apiToken: string }) {
    this.accountId = config.accountId;
    this.baseUrl = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/storage/kv/namespaces/${config.namespaceId}`;
    this.apiToken = config.apiToken;
  }

  private key(tenantId: string, keyParts: string[]): string {
    return `${tenantId}:${keyParts.join(":")}`;
  }

  async get<T>(tenantId: string, keyParts: string[]): Promise<T | null> {
    const k = this.key(tenantId, keyParts);
    const res = await fetch(`${this.baseUrl}/values/${encodeURIComponent(k)}`, {
      headers: { Authorization: `Bearer ${this.apiToken}` },
    });
    if (!res.ok) return null;
    return res.json() as T;
  }

  async put<T>(tenantId: string, keyParts: string[], value: T, ttl = 300): Promise<void> {
    const k = this.key(tenantId, keyParts);
    await fetch(`${this.baseUrl}/values/${encodeURIComponent(k)}`, {
      method: "PUT",
      headers: { Authorization: `Bearer ${this.apiToken}` },
      body: JSON.stringify(value),
      // KV API supports expiration_ttl query param
    });
  }

  async delete(tenantId: string, keyParts: string[]): Promise<void> {
    const k = this.key(tenantId, keyParts);
    await fetch(`${this.baseUrl}/values/${encodeURIComponent(k)}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${this.apiToken}` },
    });
  }
}

Step 3: Worker-Native KV Access (for existing Workers)


// wrangler.jsonc
{
  "kv_namespaces": [
    { "binding": "CACHE", "id": "<namespace-id>" }
  ]
}

// In Worker code
export default {
  async fetch(request: Request, env: Env) {
    const tenantId = getTenantFromRequest(request);
    const cacheKey = `${tenantId}:site-config`;

    // Try cache first
    const cached = await env.CACHE.get(cacheKey, "json");
    if (cached) return Response.json(cached);

    // Miss -- fetch from DB via Hyperdrive
    const config = await fetchSiteConfig(env, tenantId);

    // Cache with 5-minute TTL
    await env.CACHE.put(cacheKey, JSON.stringify(config), {
      expirationTtl: 300,
    });

    return Response.json(config);
  },
};

Step 4: Cache Integration in TRPC Routers


// Helper for cache-aside in TRPC
async function withKVCache<T>(
  ctx: TRPCContext,
  keyParts: string[],
  ttlSeconds: number,
  fetchFn: () => Promise<T>,
): Promise<T> {
  const cacheKey = `${ctx.tenantId}:${keyParts.join(":")}`;

  // Try KV cache
  const cached = await kvCache.get<T>(ctx.tenantId, keyParts);
  if (cached) return cached;

  // Fetch from DB
  const result = await fetchFn();

  // Store in KV (fire-and-forget)
  void kvCache.put(ctx.tenantId, keyParts, result, ttlSeconds);

  return result;
}

// Usage in router
export const siteRouter = createTRPCRouter({
  getConfig: permissionProtectedProcedure(["site:read"])
    .query(async ({ ctx }) => {
      return withKVCache(ctx, ["site", "config"], 300, async () => {
        const service = await getService("site-config", ctx);
        return service.getConfig();
      });
    }),
});

Step 5: Cache Invalidation


// Invalidate on write operations
export const siteRouter = createTRPCRouter({
  updateConfig: permissionProtectedProcedure(["site:write"])
    .input(updateConfigSchema)
    .mutation(async ({ ctx, input }) => {
      const service = await getService("site-config", ctx);
      const result = await service.update(input);

      // Invalidate cache
      await kvCache.delete(ctx.tenantId, ["site", "config"]);

      return result;
    }),
});

Cache Strategy by Domain

Tier 1: Long TTL (5-15 min) -- Rarely changes

Key PatternDataTTL
`{tid}:tenant:config`Tenant configuration15 min
`{tid}:site:{sid}:config`Site settings10 min
`{tid}:permissions:{roleId}`Permission sets5 min
`{tid}:menu:items`Admin menu items15 min
`{tid}:feature-flags`Feature flags5 min

Tier 2: Medium TTL (1-5 min) -- Changes occasionally

Key PatternDataTTL
`{tid}:products:list:{page}:{filters}`Product listings2 min
`{tid}:categories:tree`Category hierarchy5 min
`{tid}:analytics:dashboard`Dashboard stats1 min
`{tid}:pos:menu`POS product menu2 min

Tier 3: Short TTL (30s-1 min) -- Frequently changes

Key PatternDataTTL
`{tid}:user:{uid}:session`User session data60s
`{tid}:rate-limit:{uid}`Rate limit counters30s

Not Cached (Real-time)

KV with Metadata

Store structured metadata alongside values for cache management:


await env.CACHE.put(key, JSON.stringify(value), {
  expirationTtl: 300,
  metadata: {
    tenantId,
    cachedAt: Date.now(),
    version: "v1",
    source: "site-config-router",
  },
});

// Read with metadata
const { value, metadata } = await env.CACHE.getWithMetadata(key, "json");

Limits

MetricFreePaid
Reads/day100KUnlimited
Writes/day (different keys)1KUnlimited
Writes/s (same key)1/s1/s
Storage1 GBUnlimited
Key size512 bytes512 bytes
Value size25 MiB25 MiB
Metadata1 KiB1 KiB
Ops per Worker invocation1,0001,000

Consistency Model

KV is \1 (~60s propagation). This means:

Pricing (Paid Plan)

OperationCost
Reads$0.50/million
Writes$5.00/million
Deletes$5.00/million
List$5.00/million
Storage$0.50/GB-month

\1: ~$5-15/mo (heavy reads, light writes).

Estimated Impact