Caching
Cache tags and invalidation strategy
The project uses Next.js caching primitives (unstable_cache) with table-based cache tags for automatic invalidation.
Why Caching?
Database queries can be expensive. Caching:
- Reduces database load - Fewer queries to PostgreSQL
- Improves performance - Serve from cache instead of DB
- Lowers latency - Faster response times
- Scales better - Handle more requests with less resources
Next.js caches on the server, not the browser. Perfect for server components!
Cache Strategy
Read Operations: Cached
Operations that read data are automatically cached:
listDocuments()- Cached with table taggetDocument()- Cached with table tagcountDocuments()- Cached with table taglistTable()- Cached with table tag
Write Operations: Revalidate
Operations that write data automatically revalidate cache:
createDocument()- Revalidates table tagupdateDocument()- Revalidates table tagremoveDocument()- Revalidates table tag
This ensures cached data stays fresh.
Table Tags
Define cache tags in src/db/tags.ts:
export enum TableTags {
users = "users",
settings = "settings",
organizations = "organizations",
members = "members",
invitations = "invitations",
subscriptions = "subscriptions",
apiKeys = "apiKeys",
// Add new tables here
}Every table should have a tag for proper cache invalidation!
Adding a New Tag
When creating a new table:
- Add tag to
TableTagsenum - Use the tag in your operations
// src/db/tags.ts
export enum TableTags {
// ...existing
posts = "posts", // NEW
}How Caching Works
Automatic via Operations
When using createDrizzleOperations, caching is automatic:
import { createDrizzleOperations } from "@/db/drizzle-operations";
import { posts } from "@/db/tables/posts";
const operations = createDrizzleOperations<typeof posts, Post>({
table: posts,
});
// Cached read
export async function list() {
return operations.listDocuments(); // ✅ Cached with "posts" tag
}
// Write with revalidation
export async function create(data: NewPost) {
return operations.createDocument(data); // ✅ Revalidates "posts" tag
}Manual Cache Control
For custom queries, use unstable_cache:
import { unstable_cache } from "next/cache";
import { dbDrizzle } from "@/db";
import { TableTags } from "@/db/tags";
export const getPostWithAuthor = unstable_cache(
async (postId: string) => {
return await dbDrizzle.query.posts.findFirst({
where: eq(posts.id, postId),
with: {
author: true,
},
});
},
["post-with-author"], // Cache key
{
tags: [TableTags.posts], // Cache tag
revalidate: 3600, // Optional: revalidate after 1 hour
}
);The createDrizzleOperations abstraction automatically wraps operations with unstable_cache, so you only need manual caching for custom queries.
## Cache Invalidation
### Automatic Invalidation
Operations handle revalidation automatically:
```typescript
// This revalidates all cache entries tagged with "posts"
await operations.createDocument({
title: "New Post",
userId: "123",
});Manual Invalidation
Use revalidateTag for custom invalidation:
import { revalidateTag } from "next/cache";
import { TableTags } from "@/db/tags";
// Revalidate all "posts" cache entries
revalidateTag(TableTags.posts);
// Revalidate multiple tags
revalidateTag(TableTags.posts);
revalidateTag(TableTags.users);When to Manually Invalidate
You typically need manual invalidation when:
- External data changes - Data updated outside your app
- Complex operations - Multiple tables affected
- Batch operations - Bulk updates
- Custom queries - Not using operations abstraction
Example:
export async function publishPost(postId: string) {
await dbDrizzle
.update(posts)
.set({ published: true, publishedAt: new Date() })
.where(eq(posts.id, postId));
// Manually revalidate
revalidateTag(TableTags.posts);
}Cache Configuration
Cache Duration
Control how long cache entries live:
export const getCachedUsers = unstable_cache(
async () => {
return operations.listDocuments();
},
["users-list"],
{
tags: [TableTags.users],
revalidate: 3600, // Revalidate after 1 hour (in seconds)
}
);Options:
revalidate: false- Cache forever (until manually invalidated)revalidate: 0- No cachingrevalidate: 60- Cache for 60 seconds
No Caching
For operations that should never be cached:
import { unstable_noStore } from "next/cache";
export async function getRealTimeData() {
unstable_noStore(); // Opt out of caching
return await dbDrizzle.query.events.findMany({
where: eq(events.type, "realtime"),
});
}Performance Considerations
Cache Hit Ratio
Monitor how often cache is used vs. database queries:
export async function getWithMetrics(id: string) {
const start = Date.now();
const result = await operations.getDocument(id);
const duration = Date.now() - start;
console.log(`Query took ${duration}ms`);
return result;
}Cache Key Strategy
Use specific cache keys for better hit rates:
// ❌ Bad: Too generic
const data = unstable_cache(
() => getData(),
["data"]
);
// ✅ Good: Specific to query
const data = unstable_cache(
() => getData(),
["data", userId, filter, page.toString()]
);Stale-While-Revalidate
Allow stale data while revalidating in background:
export const getUsers = unstable_cache(
async () => operations.listDocuments(),
["users"],
{
tags: [TableTags.users],
revalidate: 60, // Revalidate every 60s
// Serve stale data while revalidating
}
);Common Patterns
Cache-Aside Pattern
Use unstable_cache with operations:
import { unstable_cache } from "next/cache";
import { TableTags } from "@/db/tags";
export const getOrganization = unstable_cache(
async (id: string) => {
return await operations.getDocument(id);
},
["organization"],
{
tags: [TableTags.organizations],
}
);Write-Through Pattern
Write to DB and invalidate cache:
export async function updateOrganization(id: string, data: Partial<Organization>) {
// Write to DB
const result = await operations.updateDocument(id, data);
// Cache automatically invalidated by operations
return result;
}Multi-Table Invalidation
Invalidate multiple tables when data spans tables:
export async function createPostWithTags(
postData: NewPost,
tagIds: string[]
) {
const result = await dbDrizzle.transaction(async (tx) => {
const [post] = await tx.insert(posts).values(postData).returning();
await tx.insert(postTags).values(
tagIds.map(tagId => ({ postId: post.id, tagId }))
);
return post;
});
// Invalidate both tables
revalidateTag(TableTags.posts);
revalidateTag(TableTags.tags);
return result;
}Debugging Cache
Check Cache Status
import { unstable_cache } from "next/cache";
export const getUsers = unstable_cache(
async () => {
console.log("🔴 Cache MISS - Querying database");
return operations.listDocuments();
},
["users"],
{
tags: [TableTags.users],
}
);
// First call: "🔴 Cache MISS"
await getUsers();
// Second call: No log (cache hit)
await getUsers();Force Cache Refresh
import { revalidateTag } from "next/cache";
// Clear cache for testing
revalidateTag(TableTags.users);
// Next call will query DB
await getUsers();Best Practices
Follow these guidelines for effective caching.
- Use operations abstraction - Automatic caching and invalidation
- Define table tags - Every table needs a tag in
src/db/tags.ts - Tag custom queries - Always add cache tags to custom queries
- Revalidate on writes - Clear cache when data changes
- Use specific cache keys - Include relevant parameters in keys
- Monitor performance - Track cache hit rates
- Consider revalidate time - Balance freshness vs. performance
- Invalidate related data - Clear cache for dependent tables
Real-World Example
From src/features/api-keys/functions.ts:
import { createDrizzleOperations } from "@/db/drizzle-operations";
import { apiKeys } from "@/db/tables";
import { ApiKey } from "@/features/api-keys/schema";
const operations = createDrizzleOperations<typeof apiKeys, ApiKey>({
table: apiKeys,
});
// ✅ Automatically cached with "apiKeys" tag
export async function list() {
return operations.listDocuments();
}
// ✅ Automatically revalidates "apiKeys" tag
export async function create(data: NewApiKey) {
return operations.createDocument(data);
}
// ✅ Automatically revalidates "apiKeys" tag
export async function update(id: string, data: Partial<ApiKey>) {
return operations.updateDocument(id, data);
}
// ✅ Automatically revalidates "apiKeys" tag
export async function remove(id: string) {
return operations.removeDocument(id);
}No manual cache management needed! 🎉
Edge Cases
Time-Sensitive Data
For data that must be real-time:
import { unstable_noStore } from "next/cache";
export async function getCurrentBidPrice() {
unstable_noStore(); // Never cache
return await dbDrizzle.query.auctions.findFirst({
orderBy: desc(auctions.currentBid),
});
}User-Specific Data
Include user ID in cache key:
export const getUserSettings = unstable_cache(
async (userId: string) => {
return operations.listDocuments(eq(settings.userId, userId));
},
["user-settings"], // Base key
{
tags: [TableTags.settings],
revalidate: 300, // 5 minutes
}
);
// Each user gets their own cache entry
await getUserSettings("user-1");
await getUserSettings("user-2");Complex Dependencies
When one table depends on another:
export async function updateUserProfile(userId: string, data: ProfileUpdate) {
await operations.updateDocument(userId, data);
// User profile affects posts display
revalidateTag(TableTags.users);
revalidateTag(TableTags.posts); // Also invalidate posts
}Next Steps
- Operations - CRUD operations with auto-caching
- Schema Definition - Define tables
- Feature Modules - Organize features
- Performance - Optimize application performance