Skip to content

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 static string[] or a function (...args) => string[], so a per-entity read can tag itself with the id. Evict by tag with revalidateTag / 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.

Setting cacheControl to anything other than no-store also opts the page into conditional GET: WebJs attaches a weak ETag and answers a matching If-None-Match with a 304, so a revalidation costs a few hundred bytes instead of the whole document. A bare max-age=60 is enough, and so is private: the public and private keywords control shared-cache storage, not whether you get an ETag.

That path only works if the page renders the same bytes twice. The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a Date.now(), a Math.random(), an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce, which is why a page under CSP is excluded from the server HTML cache. Nothing errors when this happens. The page renders correctly, every content assertion still passes, and the only symptom is a caching layer that silently never engages, so it is worth a test that renders the page twice, through its layout, and asserts the two outputs are identical.

A public value shares one copy across every visitor, so set it only on a page that renders identically for all of them. This is the same rule as revalidate below, but with none of the same protection: revalidate auto-excludes a render that reads cookies(), a session, or auth(), whereas cacheControl is emitted verbatim. Put a signed-in user's name on a page marked public, s-maxage=600 and a CDN will serve it to the next visitor. Use private for anything per-user: it still gets browser caching and conditional GET, just never a shared copy. What you do NOT have to think about is cookies (the SSR response sets none, since action CSRF is an Origin / Sec-Fetch-Site check) or the client router's partial responses. The client router's partial responses are handled for you: a reduced fragment is served private so no shared cache can store it, which holds even on a CDN that ignores Vary (Cloudflare honours only Accept-Encoding). Without that, a CDN could serve a chrome-less fragment to a full-page navigation.

Note that max-age=0 is a common default worth thinking about. It keeps the browser revalidating on every view, which is right when deploys must be visible immediately, but it means a stored copy is never reused directly. A small non-zero value is what produces real browser cache hits on back/forward and repeat visits.

Static Assets: asset()

A file in public/ is served at a stable url, so after a deploy a browser or CDN can keep serving the PREVIOUS bytes until its cache expires. Wrap the url in asset() and it gains a content hash, which the framework then serves immutable for a year:

import { html, asset } from '@webjsdev/core';

export default function RootLayout({ children }) {
  return html`<link rel="stylesheet" href=${asset('/public/app.css')}>`;
}

In production that renders /public/app.css?v=<hash>. New bytes mean a new url, so no cache can serve a stale copy, and the year-long immutable lifetime is safe precisely because the url changes when the file does. The same url un-marked gets a short max-age instead. asset() resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a page, layout, or metadata route, which render only on the server. Inside a component that ships to the browser it quietly forfeits the caching it was for: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset is fetched twice. The url stays valid either way, so this is a convention rather than something webjs check rejects. It is off in development, so dev output stays byte-identical, and only public/ paths resolve.

Two smaller rules follow from how it works. Call asset() inside the render function, not at module scope: a top-level call is a side effect the elision analyser reads as client work, so hoisting it into a constant ships the whole page or layout to the browser. And mark only files that change with a deploy: the hash is memoized for the process lifetime, so a public/ file rewritten in place while the server runs would keep its old url while being served immutable for a year. A compiled stylesheet or a committed image is the right shape; a runtime-written upload is not.

Mark the thing that FETCHES, not a hint. Do not wrap a <link rel="preload"> whose asset is really fetched by an @font-face url() in your stylesheet. The preload cache is keyed on the full url, so a versioned hint can never satisfy the un-versioned request the CSS makes, and the file downloads twice. Framework-emitted urls (your modules, the core runtime, vendor bundles) are fingerprinted automatically and need no marking.

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 or null.
  • 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