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.
- Read latency: < 5ms (hot cache)
- Write propagation: ~60 seconds globally
- Value size: up to 25 MiB
- TTL support: minimum 30 seconds
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
| Data | Read:Write Ratio | Staleness Tolerance | KV Fit |
|---|---|---|---|
| Tenant config | 1000:1 | 5 min | Excellent |
| Site settings | 500:1 | 5 min | Excellent |
| Permission sets | 200:1 | 1 min | Excellent |
| Product catalog | 100:1 | 5 min | Excellent |
| Menu items | 500:1 | 10 min | Excellent |
| Feature flags | 1000:1 | 1 min | Excellent |
| User sessions | 50:1 | 0 (real-time) | Good (short TTL) |
| Inventory levels | 10:1 | 0 (real-time) | Poor |
| Order data | 5:1 | 0 (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 Pattern | Data | TTL |
|---|---|---|
| `{tid}:tenant:config` | Tenant configuration | 15 min |
| `{tid}:site:{sid}:config` | Site settings | 10 min |
| `{tid}:permissions:{roleId}` | Permission sets | 5 min |
| `{tid}:menu:items` | Admin menu items | 15 min |
| `{tid}:feature-flags` | Feature flags | 5 min |
Tier 2: Medium TTL (1-5 min) -- Changes occasionally
| Key Pattern | Data | TTL |
|---|---|---|
| `{tid}:products:list:{page}:{filters}` | Product listings | 2 min |
| `{tid}:categories:tree` | Category hierarchy | 5 min |
| `{tid}:analytics:dashboard` | Dashboard stats | 1 min |
| `{tid}:pos:menu` | POS product menu | 2 min |
Tier 3: Short TTL (30s-1 min) -- Frequently changes
| Key Pattern | Data | TTL |
|---|---|---|
| `{tid}:user:{uid}:session` | User session data | 60s |
| `{tid}:rate-limit:{uid}` | Rate limit counters | 30s |
Not Cached (Real-time)
- Order creation/status
- Inventory quantities
- Payment processing
- Live chat messages
- Active bookings
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
| Metric | Free | Paid |
|---|---|---|
| Reads/day | 100K | Unlimited |
| Writes/day (different keys) | 1K | Unlimited |
| Writes/s (same key) | 1/s | 1/s |
| Storage | 1 GB | Unlimited |
| Key size | 512 bytes | 512 bytes |
| Value size | 25 MiB | 25 MiB |
| Metadata | 1 KiB | 1 KiB |
| Ops per Worker invocation | 1,000 | 1,000 |
Consistency Model
KV is \1 (~60s propagation). This means:
- A write in one region may not be visible in another for up to 60s
- For the same key, reads-after-writes are consistent within the same PoP
- **Acceptable for**: config, catalogs, menus, permissions, analytics
- **Not acceptable for**: inventory levels, order status, payments
Pricing (Paid Plan)
| Operation | Cost |
|---|---|
| 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
- **DB read reduction**: 30-50% for cached patterns
- **Read latency**: 50-200ms → <5ms (hot cache)
- **Neon load**: Significant reduction in connection pressure
- **Cross-instance consistency**: Shared cache across all server instances
- **Effort**: 3-5 days for core implementation, 1-2 weeks for full rollout