SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Cache
MochiCache caches server-side data — typically slow upstream API calls — with stale-while-revalidate semantics. Construct it once at module scope and share the instance across requests.
default· clock = planned — see Persistence for all features.
// src/lib/cache.ts
import { MochiCache } from 'mochi-framework';
export const pokemonCache = new MochiCache({
minTimeToStale: 10_000, // serve fresh for 10s
maxTimeToLive: 300_000, // hard expiry at 5min
});Use it from a page or API route:
<script>
import { params } from 'mochi-framework';
import { pokemonCache } from '../lib/cache';
const id = params.id ?? 'pikachu';
const pokemon = await pokemonCache.fetch(`pokemon:${id}`, async () => {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
return res.ok ? await res.json() : null;
});
</script>Caching expensive serverProps
A page’s latency is usually dominated by its data loading, not the render — so serverProps resolvers are the highest-leverage place to cache. Construct a MochiCache at module scope and wrap the slow call in fetch(), keyed per route params:
// file: src/index.ts
import { Mochi, MochiCache } from 'mochi-framework';
import { loadPokemon } from './lib/pokemon';
const pokemonCache = new MochiCache({
minTimeToStale: 10_000, // serve fresh for 10s
maxTimeToLive: 300_000, // hard expiry at 5min
});
await Mochi.serve({
routes: {
'/pokemon/:id': Mochi.page('./src/Pokemon.svelte', {
serverProps: async (_req, params) => ({
pokemon: await pokemonCache.fetch(`pokemon:${params.id}`, () => loadPokemon(params.id)),
}),
}),
},
});The first request per key pays the load; later requests follow the fresh / stale / expired lifecycle below. Everything that shapes the result must be in the key — route params as above, plus any cookie- or locals-derived dimension per the warning above.
Behavior
- Fresh (within
minTimeToStale): cached value returned, no fetch. - Stale (between
minTimeToStaleandmaxTimeToLive): cached value returned at once, fetch runs in the background and updates the cache. - Expired (past
maxTimeToLive): fetch runs synchronously and the caller waits.
API
| Method | Returns |
|---|---|
fetch(key, fn) | Promise<T> |
fetchWithStatus(key, fn) | Promise<{ value, status }> |
peek(key) | Promise<{ value, status } \| null> |
set(key, value) | Promise<void> |
markStale(key) | Promise<void> |
delete(key) | Promise<void> |
clearItems() | Promise<void> |
whenIdle() | Promise<void> |
peek(key) reports a key’s status and value without running fn, revalidating, or emitting cache:read — a pure probe that returns null on a miss. markStale(key) backdates an entry so its next read serves stale-while-revalidate. It is a no-op on a missing or already-stale key, and it never freshens or un-expires one. Both run through the storage interface, so they apply to any backend. set(key, value) writes a value directly, stamped fresh. Prefer set over delete(key) then fetch: that sequence leaves the key absent, so concurrent readers each start their own recompute.
status is 'fresh' | 'stale' | 'expired' | 'miss'.
Waiting for background revalidations
Since: 0.10.0 (not released yet): whenIdle() was added in 0.10.0.
A stale read returns at once and refreshes in the background, so the new value is not readable when fetch resolves. whenIdle() waits for those background runs to finish — including the storage write, not just the upstream call — which is what you want before shutting down, or in a test that asserts on the refreshed value.
const stale = await cache.fetchWithStatus('users', loadUsers); // 'stale', old value
await cache.whenIdle();
const fresh = await cache.fetchWithStatus('users', loadUsers); // 'fresh', new valueA run that throws settles it like any other, so a failing upstream can’t leave it hanging. ImageCache exposes the same method for its own regenerations.
Options
| Option | Default |
|---|---|
minTimeToStale | 5_000 (5s) |
maxTimeToLive | 600_000 (10min) |
storage | MemoryStorage |
serialize | none (v => v) |
deserialize | none (v => v) |
Two storage backends ship with the framework: MemoryStorage (the default) and FileStorage. Built-in SQLite and Postgres backends are planned; until then any other backend — SQLite, Postgres, Redis — needs a storage you write yourself, implementing getItem / setItem / removeItem / clear. Those methods may be synchronous or async. The cache awaits every call. When a backend needs a string or buffer, supply serialize / deserialize — for example serialize: JSON.stringify, deserialize: JSON.parse.
The default MemoryStorage accepts { maxAge, purgeInterval } for age-based eviction. With no options it never evicts.
See Persistence for how cache storage compares to the other stateful subsystems.
File-based storage
FileStorage persists the cache to disk, so it survives restarts — the only built-in persistent backend (see Persistence). It needs no serialize / deserialize:
import { MochiCache, FileStorage } from 'mochi-framework';
export const pokemonCache = new MochiCache({
minTimeToStale: 10_000,
maxTimeToLive: 300_000,
storage: new FileStorage({
directory: './.cache/pokemon',
maxAge: 300_000, // must be >= maxTimeToLive
}),
});A background sweep deletes expired files on an interval. purgeOnInit empties the directory on startup.
Binary fields (Uint8Array / Buffer) anywhere in a value round-trip transparently. By default Mochi inlines them as base64 in the JSON and returns them as Uint8Array. When values carry large binaries, set offloadBinary: true: Mochi writes each binary to its own file in a <key-hash>/ folder and puts a pointer in the JSON instead of base64. An offloaded field reads back as a lazy blob reference, so a metadata read never loads the bytes. Resolve one with readBlobRef(ref), and narrow a value with isBlobRef(value). Deleting a key also removes its blob folder. Pointers already on disk always decode, so the flag never orphans existing entries. The built-in image cache enables offloading internally.
| Option | Default | |
|---|---|---|
directory | (required) | Where cache files are written; created if missing. |
purgeOnInit | false | Delete the directory’s contents when the adapter is constructed. |
purgeInterval | 60_000 (1min) | Background sweep interval in ms. <= 0 disables the sweeper. |
maxAge | 600_000 (10min) | Files older than this are deleted by the sweep. |
offloadBinary | false | Offload binary fields to per-key blob files, read back as lazy BlobRefs. |
Only one background sweeper runs per cache directory per process. A new FileStorage on the same directory transfers the sweep to the newest instance, and with it that instance’s maxAge, so a dev-server reload that re-runs your module never stacks up duplicate sweepers. Ownership never moves back: dispose() on the newest instance ends the sweep for that directory, even if an older instance is still in use.
If a storage call throws, the cache degrades instead of failing the request: a read error recomputes via fn (reported as a miss), a write error returns the freshly computed value uncached, and a delete error is re-thrown to the caller. Every case emits a cache:error event.
Subscribing to cache events
MochiCache emits these events on mochiEvents:
| Event | Payload | When |
|---|---|---|
cache:read | { key, status } | Every cache lookup. |
cache:revalidate | { key } | A background refetch starts (stale read). |
cache:delete | { key } | A key was removed via delete(key). |
cache:sweep | { removed, durationMs } | A FileStorage background sweep deleted expired files. |
cache:pressure | { level, removed, caches, durationMs } | The OS reported low memory and Mochi drained its in-memory caches. |
cache:revalidate:failed | { key, error } | A background refetch threw; the stale value is still served. |
cache:error | { key, operation, error } | A storage get / set / remove call threw. |
Memory pressure
Since: 0.10.0 (not released yet): Memory-pressure cache draining (and the memoryPressure serve option) ships in the next Mochi release (0.10.0). This section describes the upcoming API.
When the operating system runs low on memory, Bun raises process.on("memoryPressure") and Mochi drains every
in-memory cache before the kernel starts killing processes. 'critical' (all platforms) clears them outright; 'warning' (macOS only) drops just the aged-out entries, so a store without maxAge keeps everything. Each response
emits one cache:pressure event and one consoleLogger() warning.
Only MemoryStorage participates — it is the backend that holds bytes in RAM. FileStorage is disk-backed, so
dropping it would not help. Turn the whole thing off with Mochi.serve({ memoryPressure: false }).
Reclamation runs in production only. In development (development: true) it is always disabled — the compile-heavy
boot hair-triggers the OS signal (Linux reports only 'critical'), so the reclaim would be a spurious no-op.
The raw signal is also broadcast as a memory:pressure event (payload { level }) the moment it arrives, before the
cache drain. Subscribe to reclaim resources Mochi doesn’t own — idle connection pools, worker queues, your own maps:
import { mochiEvents } from 'mochi-framework';
mochiEvents.on('memory:pressure', ({ level }) => {
if (level === 'critical') pool.drainIdle();
});// file: src/index.ts
await Mochi.serve({ memoryPressure: false, routes });consoleLogger() surfaces cache:revalidate:failed and cache:error as warnings. Use mochiEvents.setHandler to attach a custom subscriber — it replaces a prior handler under the same name, so dev re-imports do not pile up listeners:
import { mochiEvents } from 'mochi-framework';
mochiEvents.setHandler('metrics:cache-read', 'cache:read', ({ key, status }) => {
metrics.increment(`cache.${status}`, { key });
});consoleLogger() prints cache:revalidate lines by default. Pass { cache: 'verbose' } to print every read, or { cache: false } to silence cache logging.
Per-request memoization
MochiCache is process-wide and outlives the request. To collapse repeated work within a single render, use the request cache instead. It needs no TTL because entries die with the request.
Server-only
MochiCache lives on the server. Importing it into a hydratable island throws. Construct cache instances in .ts modules or page-route scripts, never inside a mochi:hydrate component.
See it in action
Live demos showing key concepts from this page