Response Cache

Skip load() + render() + compression on cache hit. Per-user safe via identity hash. Invalidate from server actions with invalidate(key) / invalidateAll(prefix).

Since v0.6, Bosia keeps an in-memory response cache that serves SSR HTML and +server.ts GET responses directly from compressed bytes when the same URL is requested again. On a cache hit there is no load(), no render(), and no compression — typically a sub-millisecond response.

The cache is safe for logged-in users because the key includes a hash of cookies and headers named in CACHE_KEYS. Two users with different session cookies get different cache entries.

How a request flows

  1. Look up <dedup-key>|i=<identity-hash> in the cache.
  2. Hit → serve the matching compressed variant (brotli, gzip, or identity) based on Accept-Encoding. Done.
  3. Miss → run metadata(), run load(), render, build HTML chunks, stream the response. Compression + cache write happen in a microtask after the response goes out.

Per-user isolation

The cache key is:

<normalized-path>?<sorted-query>|i=<identity-hash>

identity-hash is built from every cookie AND header whose name appears in CACHE_KEYS. The default value covers common session names:

CACHE_KEYS=session,sid,auth,token,jwt,Authorization

Add custom names if your app uses a different cookie or header for authentication. Any non-empty value contributes to the hash; two requests with the same set of values share a cache entry, two with different values do not.

If a route's per-user content is not keyed by anything in CACHE_KEYS, opt it out (see below).

Eligibility

Condition Result
export const cache = false on the route Skip read + write
Request method ≠ GET Skip read + write
CSP_DIRECTIVES set (CSP enabled) Skip read + write
CACHE_MAX_ENTRIES=0 Skip read + write
Response status ≠ 200 Skip write
Handler called cookies.set() Skip write
?_invalidated=… query present Skip read; still write

A skip never breaks the response — it just falls back to the normal render path.

Opting out per route

Add export const cache = false; to a +page.ts, +page.server.ts, or +server.ts file:

// +page.server.ts
import type { CacheOption } from "./$types";
export const cache: CacheOption = false;

Use this for live data (ticker, per-second counter) or pages where personalisation is not covered by CACHE_KEYS.

Server-side `invalidate()`

After a write, evict any matching cache entries so the next read serves fresh HTML:

// +page.server.ts
import { invalidate } from "bosia/server";

export const actions = {
	rename: async ({ request, locals }) => {
		await db.users.update(locals.user.id, { name: (await request.formData()).get("name") });
		invalidate("app:user");
	},
};
  • invalidate("app:user") — evict every cached page whose loader called depends("app:user").
  • invalidate("/api/posts") — evict every cached page whose loader fetched /api/posts, plus the cached /api/posts API response itself.
  • invalidateAll("/products/") — evict every entry whose path starts with the prefix.

Names mirror the existing browser-side invalidate() from bosia/client. The server version applies the same key concept to the new server cache.

Tagging loaders

depends() tags both the client loader cache AND the server response cache, so one call serves both layers:

// +page.server.ts
export async function load({ depends, locals }) {
	depends("app:user");
	return { user: locals.user };
}

When the form action runs invalidate("app:user"), both caches drop the entry and the next GET re-runs load().

API endpoints

+server.ts GET handlers are cached with the same key rules. In v0.6 they can only be invalidated by URL or prefix — there is no depends() mechanism for API handlers yet:

invalidate("/api/posts"); // exact
invalidateAll("/api/"); // prefix

Tag-based invalidation for API endpoints is on the roadmap.

Env vars

Variable Default Purpose
CACHE_KEYS session,sid,auth,token,jwt,Authorization Cookie/header names that contribute to the identity hash.
CACHE_MAX_ENTRIES 500 LRU capacity. 0 disables the cache entirely.

Both are read once at startup. Each entry holds the raw bytes plus gzip + brotli copies — typically a few KB.

Verification

  • curl -i https://localhost:9000/ | grep X-Bosia-CacheHIT on the second request, missing on the first.
  • curl -H 'Accept-Encoding: br' -IContent-Encoding: br on a hit.
  • curl -H 'Cookie: session=alice' … then Cookie: session=bob — both are misses (different identity hashes).

Trade-offs

  • Memory grows with CACHE_MAX_ENTRIES × (raw + gzip + brotli). Tune the cap for your container.
  • The cache lives in-process. A second replica has its own cache; multi-replica pub/sub invalidation is on the roadmap.
  • TTL-based expiry isn't implemented — entries live until LRU eviction or explicit invalidate(). Author writes drive eviction.