Caching
WebJs provides two complementary caching layers: cache() for server-side query result caching, and HTTP Cache-Control headers for page-level browser/CDN caching. Zero config in development (in-memory store). For horizontal scaling in production, call setStore(redisStore({ url: process.env.REDIS_URL })) once at app startup to share the cache across instances.
cache(): Server-Side Query Caching
Wrap any async function with cache() to cache its return value on the server. Same function + same arguments = cached result until TTL expires or you call invalidate().
import { cache } from '@webjsdev/server';
import { db } from '#db/connection.server.ts';
export const listPosts = cache(
async () => {
return db.query.posts.findMany({ orderBy: { createdAt: 'desc' } });
},
{ key: 'posts', ttl: 60 }
);
// Call it normally. First call hits DB, subsequent calls serve cache.
const posts = await listPosts();
Options
key(required): cache key prefix. Combined with serialized arguments to form the full key.ttl(optional): time-to-live in seconds. Default: 60.tags(optional) attaches tags for cross-module invalidation. Either a staticstring[]or a function(...args) => string[], so a per-entity read can tag itself with the id. Evict by tag withrevalidateTag/revalidateTags(see below).
Rich values are preserved. Both the cached value and the argument key go through the same rich serializer as the RPC wire (Date, Map, Set, BigInt, typed arrays, cycles), not JSON. So a warm cache hit returns the same value shape as a cold miss (a row's createdAt stays a Date), and a Map or Set argument is a distinct key per value rather than collapsing to one. Cached values do not need to be JSON-plain.
Invalidation
The cached function has an invalidate() method. Call it after mutations to clear the cache:
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';
export async function createPost(input) {
await db.insert(posts).values(input);
await listPosts.invalidate(); // next call to listPosts() will hit DB
}
Invalidation clears the no-args cache key. Argument-specific keys (from calls with different arguments) expire naturally via TTL. To evict a specific argument's entry (for example one post id), tag the read and use revalidateTag (next section) rather than waiting on the TTL.
Tag-based invalidation (revalidateTag)
The invalidate() method only clears the no-args base key, so for a parameterized read each argument produces a distinct key. Add tags to a cache() so an unrelated mutation can evict the right entries without importing the wrapper. Tags are either a static string[] or a function (...args) => string[] that derives a per-entity tag from the arguments:
export const postById = cache(
async (id) => db.query.posts.findFirst({ where: { id } }),
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
);
export const listPosts = cache(
async () => db.query.posts.findMany(),
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
);
A mutating server action then calls revalidateTag(tag) after the write. It works across modules (the comments module evicts a posts-module read with no import of the wrapper):
// modules/comments/actions/create-comment.server.ts
'use server';
import { revalidateTag, revalidatePath } from '@webjsdev/server';
import { db } from '#db/connection.server.ts';
import { comments } from '#db/schema.server.ts';
export async function createComment(input) {
await db.insert(comments).values(input);
await revalidateTag('post:' + input.postId); // postById(postId) recomputes
await revalidateTag('posts'); // listPosts recomputes
await revalidatePath('/blog'); // also evict the cached HTML
return { success: true };
}
revalidateTag('post:5') evicts ONLY the id-5 entry, leaving other ids cached. revalidateTags([...]) clears several tags at once. This is the fix for the old argument-key leak. Tag a per-argument read and evict the exact id by tag instead of relying on a short TTL. An untagged cache() is untouched by any revalidateTag. Both revalidateTag and revalidateTags are imported from @webjsdev/server.
The mutation-to-read contract. A read declares the tags it belongs to, and a mutation declares the tags it evicts. The two never import each other. This is the same pairing that HTTP-verb server actions express declaratively. A GET action exports const tags = (id) => [...] to tag its cached response, and a mutation exports const invalidates = (id) => [...] so that on completion the framework evicts those tags (via revalidateTags) and reports them to the client so a later read revalidates. Tagging a cache() read with the same tag a verb action invalidates makes one eviction reach both the action response cache and the cache() data.
Tag invalidation evicts cached DATA, revalidatePath evicts cached HTML. Together they are the server cache invalidation surface, both imported from @webjsdev/server.
Multi-instance note. On the built-in memory and Redis stores the tag index is a real set (a native Set in memory, a Redis SADD set), so adding a cache key to a tag is an atomic insert. Two mutations appending to the same tag concurrently (across Redis instances, or interleaved in one process) no longer lose an entry, so revalidateTag reliably evicts every tagged key. A custom store that does not implement the optional atomic-set methods falls back to the older non-atomic JSON path, where a concurrent append can be lost; there, prefer a short ttl as the cross-instance floor. The index entry carries the cache TTL so it self-prunes either way.
HTTP Cache-Control: Page-Level Caching
For page-level caching served to browsers and CDNs, use the metadata.cacheControl export in any page.ts:
// app/posts/page.ts
export const metadata = {
title: 'Posts',
cacheControl: 'public, max-age=60, stale-while-revalidate=300',
};
This sets the standard Cache-Control header on the HTTP response. Browsers and CDNs cache the rendered page without any server-side state.
Server HTML Response Cache (export const revalidate)
For a page that renders identical HTML for every visitor, opt into the server HTML response cache so the SSR pipeline runs once per window instead of once per request (webjs's no-build equivalent of Next.js's Full Route Cache and ISR). Declare a revalidation window on the page module:
// app/blog/page.ts
export const revalidate = 60; // seconds: cache this page's HTML for 60s
export default async function Blog() {
const posts = await listPosts();
return html`...`;
}
Safety. Caching is opt-in and conservative, because a wrongly-cached per-user page is a data leak. Declaring revalidate asserts this page is the same for everyone for N seconds. The cache is keyed by the full URL (path plus search) only, with no per-user keying, so a page that reads cookies(), a session, or any per-user data MUST NOT set revalidate. The framework also refuses to cache any response that is not a 200, is a streamed Suspense body, sets any Set-Cookie, or runs under CSP. SSR responses carry no framework cookie (action CSRF is an Origin / Sec-Fetch-Site check, not a token cookie), so a cacheable page is cookieless and safe to share across visitors.
Framework defense, not just the contract. When the render reads per-user state through a framework helper (cookies(), headers(), getSession(), or auth()), the framework auto-marks the request dynamic and refuses to cache it even if you set revalidate, warning you once with the page path. So a wrong revalidate on a cookie-reading or auth()-gated page fails safe (served fresh) instead of leaking. An auth-gated dashboard page that does const session = await auth() is auto-excluded. The loud caveat is that this only catches reads THROUGH those helpers. A page that varies its body by an inbound auth cookie or Authorization header but reads it raw (not via cookies() / headers() / getSession() / auth()) and sets no new Set-Cookie WILL be cached and served to a logged-out visitor. Read per-user request state through the framework helpers, which auto-exclude the page, or never set revalidate on a per-user page.
Evict on a write with revalidatePath from a server action:
// modules/blog/actions/publish-post.server.ts
'use server';
import { revalidatePath } from '@webjsdev/server';
export async function publishPost(input) {
// ... persist via Drizzle ...
await revalidatePath('/blog'); // next /blog request re-renders fresh
return { success: true };
}
revalidatePath(path) evicts the server HTML cache for one path, and revalidateAll() clears everything. This is distinct from the client-side revalidate() from @webjsdev/core, which evicts the browser snapshot cache used by client navigation. Time-based eviction is handled automatically by the store TTL (the revalidate seconds).
Multi-instance note. revalidatePath(path) deletes a store key, so it reaches every instance sharing a Redis store. revalidateAll() bumps an in-process counter, so on a multi-instance deploy it only flushes the instance it ran on, and peers keep serving until their own TTL expires. For a multi-instance (Redis) deploy, prefer a short revalidate TTL (the time-based floor that always holds cross-instance), use revalidatePath per mutation as the reliable cross-instance primitive, and treat revalidateAll() as a single-instance or dev convenience.
Low-Level Cache Store
Both cache() and the rate limiter are built on a pluggable cache store. You can use it directly for custom caching needs:
import { getStore, setStore, redisStore } from '@webjsdev/server';
// Get the default store (memoryStore in dev)
const store = getStore();
// Read/write raw values
await store.set('user:42', JSON.stringify({ name: 'Ada' }), 300_000); // TTL in ms
const raw = await store.get('user:42');
await store.delete('user:42');
// Atomic increment (used by rate limiter)
const count = await store.increment('api:hits:192.168.1.1', 60_000);
Stores
memoryStore (default)
In-process LRU Map. Fast, zero dependencies, single-instance only. Data is lost on restart, intentionally for dev.
redisStore (production)
Redis-backed store for multi-instance deployments. Set it explicitly at app startup:
import { setStore, redisStore } from '@webjsdev/server';
setStore(redisStore({ url: process.env.REDIS_URL }));
Store API
store.get(key): returns the cached string ornull.store.set(key, value, ttlMs?): stores a string value with optional TTL in milliseconds.store.delete(key): removes a key.store.increment(key, ttlMs?): atomically increments a counter. Returns the new value. Creates the key with value 1 if it does not exist.
Internal Usage
Several framework subsystems use the cache store as their backing store:
- cache(): server-side function result caching.
- Rate limiter: uses
store.increment()with TTL to track request counts per window. - Sessions:
storeSessionStorage()persists session data in the cache when using server-side sessions. - Auth: database session strategy stores auth sessions in the cache store.
Because they all share the same store, switching from memory to Redis upgrades everything at once.
Next Steps
- Sessions: session middleware built on the cache store
- Authentication: NextAuth-style auth with providers
- Middleware: rate limiting and other middleware that uses the cache