Caching layer for Prisma on Cloudflare Workers. Handles invalidation graphs, edge consistency, and multi-tenant isolation.
Caching at the edge is easy. Consistency at the edge is hard.
When your cache is replicated across 300 locations:
This module addresses these at different levels depending on your tier.
| This Module | Prisma Accelerate | Redis + Manual | |
|---|---|---|---|
| Consistency | |||
| Read-your-writes guarantee | ✓ Pro tier | ✗ | Manual |
| Monotonic reads | ✓ Pro tier | ✗ | Manual |
| Security | |||
| DB stays in private network | ✓ | ✗ Requires endpoint | Depends |
| Tenant data isolation | ✓ Pro + DO | ✗ | Manual |
| Invalidation | |||
| Automatic on write | ✓ Model-level (free), PK-level (pro) | ✗ Manual calls | ✗ Manual |
| FK relationship traversal | ✓ Pro tier | ✗ | ✗ |
| FK change detection (old + new) | ✓ Pro tier | ✗ | ✗ |
| Nested relation write detection | ✓ Pro tier | ✗ | ✗ |
| Field-aware (skip irrelevant) | ✓ Pro tier | ✗ | ✗ |
| Instant logical eviction | ✓ Epoch-based | ✗ TTL-based | ✓ Pub/sub |
| Budget controls | ✓ | ✗ | ✗ |
| Resilience | |||
| Request deduplication | ✓ | ✗ | Manual |
| Circuit breaker | ✓ | ✗ | Manual |
| Retry with graceful degradation | ✓ Pro tier | ✗ | Manual |
| Caching | |||
| Two-tier (L1 + L2) | ✓ | ✗ | Manual |
| Composite PK support | ✓ | ✓ | Manual |
| Scale | |||
| Per-tenant capacity | ✓ Pro + DO | ✗ Shared pool | ✗ Shared |
| Hot tenant detection | ✓ Pro + DO | ✗ | Manual |
| Infrastructure | |||
| Connection pooling | ✗ (use Hyperdrive) | ✓ Built-in | ✗ |
| Managed service | ✗ Self-host | ✓ | Varies |
| Edge-native | ✓ Cloudflare | ✓ Multi-cloud | ✗ |
| Works outside Cloudflare | ✗ | ✓ | ✓ |
| Operational | |||
| Setup complexity | Low (free) to Medium (pro) | Low | High |
| Behavior | Free | Pro |
|---|---|---|
| Invalidation scope | Model-level only | PK-level precision |
| What gets invalidated | All Post cache entries |
Only Post:42 cache entries |
| Read-your-writes | ✗ | ✓ |
| Monotonic reads | ✗ | ✓ |
| Transaction awareness | ✗ | ✓ |
| FK traversal | Model-level | PK-level with before/after diff |
| Tenant isolation (DO) | ✗ | Optional |
| Request deduplication | ✓ | ✓ |
| Circuit breaker | ✓ | ✓ |
| Bulk concurrency | 3 | 6 |
| Max bulk rows | 150 | 600 |
| Max cached value size | 10 KB | 128 KB |
Free tier is appropriate for: Read-heavy workloads with infrequent writes, simple data models, development/staging environments.
Pro tier is appropriate for: Write-heavy workloads, complex relational data, multi-tenant SaaS, applications requiring strong consistency.
Best for getting started, development, or read-heavy apps with simple invalidation needs.
What you get: Cached reads, model-level invalidation on writes, request deduplication, circuit breaker.
What you don't get: PK-level precision, read-your-writes, FK traversal to specific records.
wrangler.toml:
name = "my-app"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
schema.prisma:
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
generator cache {
provider = "prisma-boost"
output = "./generated"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
comments Comment[]
}
model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id])
postId Int
}
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { createCacheExtension } from 'prisma-boost';
import { createKVAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
DB: D1Database;
CACHE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: createKVAdapter({ kv: env.CACHE }),
config: cacheConfig,
version: 'v1',
accountTier: 'free',
executionCtx: ctx,
}),
);
// Cached read
const posts = await prisma.post.findMany({
where: { published: true },
});
// Write triggers model-level invalidation
// ALL Post and Comment caches are invalidated, not just specific records
await prisma.comment.create({
data: { content: 'Nice!', postId: 1 },
});
return Response.json(posts);
},
};
What happens on write:
await prisma.post.update({
where: { id: 42 },
data: { title: 'New Title' },
});
// Invalidates: ALL Post cache entries (not just Post:42)
// Related Comment caches also invalidated at model level
Same features as Level 1, but with your existing PostgreSQL database via Hyperdrive.
Additional wrangler.toml:
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "your-hyperdrive-id"
schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import { createCacheExtension } from 'prisma-boost';
import { createKVAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
HYPERDRIVE: Hyperdrive;
CACHE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const pool = new Pool({
connectionString: env.HYPERDRIVE.connectionString,
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: createKVAdapter({ kv: env.CACHE }),
config: cacheConfig,
version: 'v1',
accountTier: 'free',
executionCtx: ctx,
}),
);
// Same behavior as Level 1
},
};
Enables precision invalidation: updating Post:42 only invalidates Post:42, not all Posts.
What you gain over free tier:
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { createCacheExtension } from 'prisma-boost';
import { createKVAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
DB: D1Database;
CACHE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: createKVAdapter({ kv: env.CACHE }),
config: cacheConfig,
version: 'v1',
accountTier: 'pro',
executionCtx: ctx,
}),
);
// Update with precision invalidation
await prisma.post.update({
where: { id: 42 },
data: { title: 'New Title' },
});
// Invalidates: Only Post:42, not all Posts
// Read-your-writes: guaranteed to see the update
const post = await prisma.post.findUnique({ where: { id: 42 } });
// Returns { title: 'New Title' }, not stale cache
// FK change detection
await prisma.comment.update({
where: { id: 99 },
data: { postId: 43 }, // was postId: 42
});
// Invalidates: Comment:99, Post:42 (old parent), Post:43 (new parent)
return Response.json(post);
},
};
Transaction awareness:
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: 1 }, data: { balance: 100 } });
await tx.order.create({ data: { userId: 1, total: 50 } });
// Reads inside transaction to written scopes bypass cache
const user = await tx.user.findUnique({ where: { id: 1 } });
// Returns fresh data, not pre-transaction cache
});
// After commit, subsequent reads see committed state
const user = await prisma.user.findUnique({ where: { id: 1 } });
Combines fast per-colo Cache API (L1) with durable global KV (L2).
What you gain: Faster reads (~1ms from L1 vs ~20ms from L2), with KV as fallback.
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { createCacheExtension } from 'prisma-boost';
import {
createTwoTierAdapter,
createCFCacheAdapter,
createKVAdapter,
} from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
DB: D1Database;
CACHE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const adapter = new PrismaD1(env.DB);
const cacheAdapter = createTwoTierAdapter({
l1: createCFCacheAdapter({
baseUrl: 'https://cache.your-domain.com',
}),
l2: createKVAdapter({ kv: env.CACHE }),
l1TTLRatio: 0.1, // L1 TTL = 10% of L2 TTL
promoteOnGet: true, // L2 hits get promoted to L1
});
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: cacheAdapter,
config: cacheConfig,
version: 'v1',
accountTier: 'pro',
executionCtx: ctx,
}),
);
// Read path:
// 1. Check L1 (Cache API, ~1ms, per-colo)
// 2. Miss → Check L2 (KV, ~20ms, global)
// 3. L2 hit → return + promote to L1 via waitUntil
// 4. L2 miss → database query
},
};
For multi-tenant SaaS where tenant cache state must be isolated.
What you gain: Per-tenant Durable Objects, physical isolation of cache metadata, tenant-specific capacity.
wrangler.toml:
name = "my-app"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
[[durable_objects.bindings]]
name = "EPOCH_COORDINATOR"
class_name = "EpochCoordinator"
[[migrations]]
tag = "v1"
new_classes = ["EpochCoordinator"]
src/coordinator.ts:
export { EpochCoordinator } from 'prisma-boost/coordinator';
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { createCacheExtension } from 'prisma-boost';
import { createKVAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
DB: D1Database;
CACHE: KVNamespace;
EPOCH_COORDINATOR: DurableObjectNamespace;
}
// Your auth middleware provides this
function getSession(request: Request): { userId: string; tenantId: string } {
// ... your auth logic
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const session = getSession(request);
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: createKVAdapter({ kv: env.CACHE }),
config: cacheConfig,
version: 'v1',
accountTier: 'pro',
executionCtx: ctx,
coordinator: {
namespace: env.EPOCH_COORDINATOR,
},
getPrivateContext: () => ({
userId: session.userId,
tenantId: session.tenantId,
}),
}),
);
// Tenant A's cache operations use DO "epoch-tenant-a"
// Tenant B's cache operations use DO "epoch-tenant-b"
// Complete isolation
},
};
For high-traffic tenants that need more than 1,000 req/s.
What you gain: Per-tenant shard configuration, hot tenant detection, capacity recommendations.
src/index.ts:
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
import { createCacheExtension } from 'prisma-boost';
import { createKVAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from '../prisma/generated/cacheConfig';
interface Env {
DB: D1Database;
CACHE: KVNamespace;
EPOCH_COORDINATOR: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const session = getSession(request);
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter }).$extends(
createCacheExtension({
adapter: createKVAdapter({ kv: env.CACHE }),
config: cacheConfig,
version: 'v1',
accountTier: 'pro',
executionCtx: ctx,
coordinator: {
namespace: env.EPOCH_COORDINATOR,
sharding: {
defaultShards: 32, // 32k req/s for user/PK scopes
publicShards: 4, // 4k req/s for public data
tenantShards: {
'acme-corp': 8, // 8k req/s dedicated
'widgets-inc': 4, // 4k req/s dedicated
// Other tenants get 1 DO each (1k req/s)
},
},
},
getPrivateContext: () => ({
userId: session.userId,
tenantId: session.tenantId,
}),
}),
);
},
};
Hot tenant detection (in admin/monitoring code):
import { PrivateShardRouter } from 'prisma-boost/coordinator';
// In your monitoring endpoint
const router = new PrivateShardRouter(shardingConfig);
const hotTenants = router.detectHotTenants(60_000);
// { 'acme-corp': 2847, 'startup-xyz': 1523 }
const recommended = router.recommendShardCount('startup-xyz');
// 2 (based on observed traffic)
// Add to config: tenantShards: { 'startup-xyz': 2 }
For local development or running Prisma in traditional Node.js servers while caching in Cloudflare KV.
When to use:
wrangler devLimitations compared to Workers:
Setup:
1. Get Cloudflare credentials:
# Account ID from dashboard or:
wrangler whoami
# Create API token with KV read/write permissions:
# https://dash.cloudflare.com/profile/api-tokens
# Template: Edit Cloudflare Workers
# Permissions: Account.Workers KV Storage (Edit)
# Get KV namespace ID:
wrangler kv:namespace list
2. Environment variables (.env.local):
CF_ACCOUNT_ID=your-account-id
CF_NAMESPACE_ID=your-kv-namespace-id
CF_API_TOKEN=your-api-token
DATABASE_URL=file:./dev.db
3. Node.js application:
import { PrismaClient } from '@prisma/client';
import { createCacheExtension } from 'prisma-boost';
import { createRemoteCloudflareAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from './prisma/generated/cacheConfig';
const adapter = createRemoteCloudflareAdapter({
accountId: process.env.CF_ACCOUNT_ID!,
namespaceId: process.env.CF_NAMESPACE_ID!,
apiToken: process.env.CF_API_TOKEN!,
namespace: 'dev-cache', // Optional key prefix
operationTimeout: 15000, // 15s timeout for API calls
generatedConfig: cacheConfig,
d1Binding: null, // Not needed for remote adapter
});
// Mock execution context for Node.js
const mockExecutionCtx = {
waitUntil: (promise: Promise<any>) => {
promise.catch((err) => console.error('Background task failed:', err));
},
passThroughOnException: () => {},
};
const prisma = new PrismaClient().$extends(
createCacheExtension({
adapter,
executionCtx: mockExecutionCtx,
version: 'v1',
config: cacheConfig,
accountTier: 'pro', // or 'free'
invalidationPolicy: {
strategy: 'epoch',
allowIncomplete: true,
},
}),
);
// Use normally
const posts = await prisma.post.findMany();
// Manual invalidation works
await prisma.$invalidateCache('Post');
4. Testing with Vitest:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { createCacheExtension } from 'prisma-boost';
import { createRemoteCloudflareAdapter } from 'prisma-boost/adapters';
import * as cacheConfig from './prisma/generated/cacheConfig';
describe('Cache invalidation', () => {
let prisma: ReturnType<typeof createCachedClient>;
function createCachedClient() {
const baseClient = new PrismaClient();
const adapter = createRemoteCloudflareAdapter({
accountId: process.env.CF_ACCOUNT_ID!,
namespaceId: process.env.CF_NAMESPACE_ID!,
apiToken: process.env.CF_API_TOKEN!,
namespace: 'test-cache',
operationTimeout: 15000,
generatedConfig: cacheConfig,
d1Binding: null,
});
const mockExecutionCtx = {
waitUntil: (promise: Promise<any>) => promise.catch(() => {}),
passThroughOnException: () => {},
};
return baseClient.$extends(
createCacheExtension({
adapter,
executionCtx: mockExecutionCtx,
version: 'v1-test',
config: cacheConfig,
accountTier: 'pro',
invalidationPolicy: { strategy: 'epoch' },
}),
);
}
beforeAll(() => {
prisma = createCachedClient();
});
it('should invalidate cache on update', async () => {
const user = await prisma.user.create({
data: { email: '[email protected]', name: 'Test' },
});
// Prime cache
const before = await prisma.user.findUnique({ where: { id: user.id } });
expect(before?.name).toBe('Test');
// Update
await prisma.user.update({
where: { id: user.id },
data: { name: 'Updated' },
});
// Wait for KV propagation
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should see updated data
const after = await prisma.user.findUnique({ where: { id: user.id } });
expect(after?.name).toBe('Updated');
});
});
Performance characteristics:
| Operation | Workers (KV binding) | Node.js (Remote API) |
|---|---|---|
| Cache read | 1-5ms | 50-200ms |
| Cache write | 1-5ms | 50-200ms |
| Epoch bump | 1-5ms | 50-200ms |
| Invalidation list | 5-20ms | 100-500ms |
When NOT to use:
Best practices:
// Enable circuit breaker for API failures
const adapter = createRemoteCloudflareAdapter({
// ... config
enableCircuitBreaker: true, // Default: true
operationTimeout: 10000, // Fail fast on slow API
});
// Use longer TTLs to reduce API calls
createCacheExtension({
adapter,
defaultTTL: 3600, // 1 hour vs 5 min in Workers
// ...
});
// Batch cleanup in tests
afterAll(async () => {
// Clean up test data
const { keys } = await adapter.list('test-cache:', { limit: 1000 });
if (keys.length > 0) {
await adapter.deleteMany(keys);
}
});
Skip invalidation when writes don't affect cached fields.
createCacheExtension({
accountTier: 'pro',
// ... other config
fieldTracking: {
Post: {
enabled: true,
neverInvalidateOn: ['viewCount', 'lastViewedAt', 'impressions'],
alwaysInvalidateOn: ['status', 'publishedAt', 'deletedAt'],
},
User: {
enabled: true,
neverInvalidateOn: ['lastLoginAt', 'loginCount'],
},
},
});
How it works:
// Cache contains: { id: 1, title: 'Hello', content: '...' }
await prisma.post.update({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
// viewCount is in neverInvalidateOn
// Cached fields don't include viewCount
// → Invalidation skipped
await prisma.post.update({
where: { id: 1 },
data: { status: 'archived' },
});
// status is in alwaysInvalidateOn
// → Invalidation triggered regardless of cached fields
Fallback strategies:
epoch-model: Bump the entire model's epoch (coarsest, safest)epoch-prefix: Bump epoch for the specific prefix being invalidatedallow-incomplete: Stop and mark result as incompleteObserving results:
createCacheExtension({
// ... other config
onInvalidate: (model, result) => {
console.log({
model,
deleted: result.deleted,
incomplete: result.incomplete,
costEstimate: result.costEstimate,
fallbackReason: result.details?.fallbackReason,
});
},
onError: (error, operation) => {
console.error(`Cache error in ${operation}:`, error);
},
});
Safe Auto-Caps (Budget Protection): Uses Cloudflare Analytics Engine invalidation telemetry to keep KV usage inside free-tier / budget constraints without overreacting to spikes:
_sample_interval weighting) to avoid single-run noiseAffinity-Aware Sharding (Coordination Hints): Learns which model mutations tend to invalidate other models and uses that as a hint to reduce cross-shard coordination:
(mutatedModel → targetModel) as individual events (not comma-joined lists)Hit Rate Monitoring: Collects cache performance metrics per request:
ctx.waitUntil() (zero request latency)1. Create Analytics Engine Datasets
wrangler analytics-engine create-dataset cacheInvalidationEvents
wrangler analytics-engine create-dataset cachePerformance
2. Bind in wrangler.toml
[[analytics_engine_datasets]]
binding = "CACHE_INVALIDATION_EVENTS"
dataset = "cacheInvalidationEvents"
[[analytics_engine_datasets]]
binding = "CACHE_ANALYTICS"
dataset = "cachePerformance"
3. Configure Extension
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const cached = await env.DB.$extends(
createCacheExtension({
adapter: cloudflareKVAdapter(env.CACHE),
config: generatedConfig,
version: 'v1',
executionCtx: ctx,
analytics: {
dataset: env.CACHE_ANALYTICS,
enabled: true,
},
}),
);
const result = await cached.user.findMany({});
ctx.waitUntil(cached.$cleanup());
return Response.json(result);
},
};
4. Enable Auto-Caps (Optional)
Set credentials for generator to query production metrics:
# Required for auto-tuning
CF_ACCOUNT_ID=your-account-id
CF_API_TOKEN=your-api-token # Analytics Engine read permission
CF_ANALYTICS_DATASET=cacheInvalidationEvents
# Optional: Monthly budget enforcement
CF_MAX_MONTHLY_BUDGET_USD=5.00 # Disable caching if exceeded
Generator queries Analytics Engine during prisma generate:
npx prisma generate
If CF_MAX_MONTHLY_BUDGET_USD is set:
90% spent: Automatically degrades to epoch-only invalidation (no KV deletes)
WARN: Degrading to epoch-only: spent $4.50 of $5.00 monthly limit
100% spent: Disables cache invalidation entirely (all DB queries, no KV writes)
ERROR: Monthly budget EXCEEDED: $5.03 of $5.00 - DISABLING cache
Budget resets monthly. Spent amount persists in isolate state across requests.
Model hit rates:
SELECT
model,
SUM(modelHits) as hits,
SUM(modelMisses) as misses,
ROUND(AVG(modelHitRate) * 100, 1) as hit_rate_pct
FROM cachePerformance
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY model
ORDER BY hits + misses DESC;
Invalidation costs:
SELECT
DATE(timestamp) as day,
SUM(double9) as total_cost_usd,
SUM(double2) as rows_deleted,
SUM(double5) as list_ops
FROM cacheInvalidationEvents
WHERE timestamp > NOW() - INTERVAL '30' DAY
GROUP BY day
ORDER BY day DESC;
Budget exceeded events:
SELECT
index1 as model,
SUM(CASE WHEN blob3 = '1' THEN _sample_interval ELSE 0 END) as exceeded,
SUM(_sample_interval) as total,
ROUND(exceeded * 100.0 / total, 1) as exceeded_pct
FROM cacheInvalidationEvents
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY model
HAVING exceeded_pct > 5;
No additional KV operations - metrics submit via waitUntil() after response sent.
Things that happen without configuration:
| Scenario | What happens |
|---|---|
| Query needs PK for invalidation but user only selected other fields | PK fields silently added to select |
| 100 concurrent identical queries | Deduplicated to 1 database call |
| Cache backend failing | Circuit breaker opens, requests go direct to DB |
| Cached value exceeds size limit | Skipped, not cached |
| Epoch read times out | Retry, then treat as epoch 0 (cache miss) |
| Background cache write fails | Logged, response unaffected |
| Tenant ID is reserved word | Rejected with error |
| FK changes from Post:42 to Post:43 | Both Post:42 and Post:43 invalidated |
npm install prisma-boost
generator cache {
provider = "prisma-boost"
output = "./generated"
}
npx prisma generate