Caching — Valkey, and graceful degradation
Some answers are expensive to compute and cheap to remember: a dashboard rollup, a
rate-limit counter, the result of a slow upstream call. A cache is where you remember
them. This stack's cache is fusion-cache — a tiny JSON key-value store
over Valkey (the open-source Redis fork), turned on by a single
environment variable and built so that the cache being down makes the app slower,
never broken.
Coming from React + Django? This is Django's cache framework.
cacheGet/cacheSetarecache.get/cache.set;VALKEY_URLis pointingCACHES["default"]atdjango_redis.cache.RedisCache. And like Django falling back toLocMemCache(or just always-miss) when you don't configure a backend, the app here runs fine with noVALKEY_URLat all — everycacheGetsimply returnsnulland everycacheSetis a no-op. The one twist Django doesn't make as explicit: even with Valkey configured, a connection failure also degrades to a miss rather than raising.
The shape of it
fusion-cache is a server-only leaf package with a flat surface. Four functions are a
TTL'd JSON cache; one more hands the same backend to Better Auth as a
shared store. Everything keys off one predicate, isCacheEnabled().
| Function | Does | When disabled / unreachable |
|---|---|---|
isCacheEnabled() | Boolean(process.env.VALKEY_URL) | returns false |
cacheGet<T>(key) | read + JSON.parse a value | returns null (a miss) |
cacheSet(key, val, ttl) | JSON.stringify + write with a TTL (seconds) | no-op |
cacheDelete(key) | drop a key | no-op |
cacheTtl(key) | remaining TTL in seconds | returns null |
getSecondaryStorage() | a { get, set, delete } string store for Better Auth | returns null |
Two design rules run through the whole package and explain almost every line:
- Every function checks
isCacheEnabled()first. No URL, no work — it returns the "absent" value before touching the network. - Every Valkey call is wrapped in
try/catchthat degrades to the absent value. A reachable-but-failing Valkey is treated exactly like a miss. The cache is an optimisation, never a dependency.
The connection is lazy and fails fast
Before the cache API, two helpers set the tone. The enabled-gate is one line, and the
client is created lazily — cached on globalThis so dev-server hot reloads don't open a
new socket each time:
// fusion-cache/src/index.ts
const PREFIX = "fusion:";
const globalCache = globalThis as { fusionCacheValkey?: Valkey };
export function isCacheEnabled(): boolean {
return Boolean(process.env.VALKEY_URL);
}
function getClient(): Valkey {
if (globalCache.fusionCacheValkey) return globalCache.fusionCacheValkey;
const client = new Valkey(process.env.VALKEY_URL!, {
// Connect on first use; fail commands fast instead of queueing forever
// when the server is down — degradation beats hanging requests.
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
// Without a listener a connection refusal becomes an unhandled error event.
client.on("error", (err) => console.warn("[cache] valkey error:", err.message));
globalCache.fusionCacheValkey = client;
return client;
}Those three iovalkey options are the entire
"never hang" story, and they're worth internalising:
lazyConnect: true— no TCP dial happens at import time. Importing the package on a box with no Valkey costs nothing; the socket only opens on the first real command.maxRetriesPerRequest: 1— a command tries once. It doesn't sit in a retry loop while a user waits.enableOfflineQueue: false— commands issued while disconnected are rejected immediately instead of buffered until a connection that may never come. That rejection is what thetry/catchin each function turns into a clean miss.
The trade-off lands in the example: because the very first command after a cold start can race the still-connecting socket, the sandbox warms the connection on page load so the user's first real click hits a live link.
Coming from Django?
django_redisdefaults to the opposite — it'll raise on a dead backend unless you setIGNORE_EXCEPTIONS.fusion-cachebakes the "ignore + log + miss" behaviour in, because in this stack a cache is always optional and a slow page beats a 500.
The JSON cache API
The four read/write functions are the whole everyday surface. Each one is gated and wrapped; misses, a disabled cache, and errors are indistinguishable to the caller — all return the absent value:
/** Read a JSON value. Misses, disabled cache and errors all return null. */
export async function cacheGet<T>(key: string): Promise<T | null> {
if (!isCacheEnabled()) return null;
try {
const raw = await getClient().get(PREFIX + key);
return raw === null ? null : (JSON.parse(raw) as T);
} catch {
return null;
}
}
/** Write a JSON value with a TTL. A no-op when disabled or unreachable. */
export async function cacheSet(key: string, value: unknown, ttlSeconds: number): Promise<void> {
if (!isCacheEnabled()) return;
try {
await getClient().set(PREFIX + key, JSON.stringify(value), "EX", ttlSeconds);
} catch {
/* degraded — next read is a miss */
}
}
export async function cacheDelete(key: string): Promise<void> {
if (!isCacheEnabled()) return;
try {
await getClient().del(PREFIX + key);
} catch {
/* degraded */
}
}
/** Remaining TTL in seconds, or null when missing/disabled. */
export async function cacheTtl(key: string): Promise<number | null> {
if (!isCacheEnabled()) return null;
try {
const ttl = await getClient().ttl(PREFIX + key);
return ttl >= 0 ? ttl : null;
} catch {
return null;
}
}Note the fusion: key prefix applied on every operation: it namespaces this app's
keys inside a Valkey instance that might be shared with other services, so a del("user:1")
here can't collide with someone else's user:1. Values are JSON — you cache structured
objects, not just strings, and cacheGet<T>(key) gives you the type back.
Shared storage for Better Auth
The second half of the package exists for one specific consumer. Better Auth
can offload its rate-limit and session bookkeeping to an external store via a
secondaryStorage option; wiring it makes those limits hold across instances instead
of resetting per process. getSecondaryStorage() adapts the cache into the exact
{ get, set, delete } shape Better Auth expects — string values (not JSON), under an
auth: sub-prefix — and returns null when the cache is off, so the caller simply omits
the option:
/**
* The string-level store Better Auth wants for `secondaryStorage` — wiring
* it makes auth rate limits shared across instances instead of per-process.
* Returns null while the cache is disabled; the caller skips the option.
*/
export function getSecondaryStorage(): {
get: (key: string) => Promise<string | null>;
set: (key: string, value: string, ttl?: number) => Promise<void>;
delete: (key: string) => Promise<void>;
} | null {
if (!isCacheEnabled()) return null;
return {
get: async (key) => {
try {
return await getClient().get(`${PREFIX}auth:${key}`);
} catch {
return null;
}
},
set: async (key, value, ttl) => {
try {
if (ttl) await getClient().set(`${PREFIX}auth:${key}`, value, "EX", ttl);
else await getClient().set(`${PREFIX}auth:${key}`, value);
} catch {
/* degraded */
}
},
delete: async (key) => {
try {
await getClient().del(`${PREFIX}auth:${key}`);
} catch {
/* degraded */
}
},
};
}The null-when-disabled return is the integration pattern in miniature: a consumer writes
secondaryStorage: getSecondaryStorage() ?? undefined, and the feature lights up only when
Valkey is present. In the example this is a documented, deliberately-unwired seam — the
auth module notes secondaryStorage → fusion-cache [not wired], because
the demo runs single-instance where a per-process limiter is fine. The capability is ready;
flipping it on is one line when a deployment goes multi-instance.
Coming from Django?
secondaryStorageis the same idea as backing Django's rate limiting or session store with Redis so severalgunicornworkers share one counter instead of each keeping its own. Without it, every instance limits independently — usually fine in dev, usually not what you want behind a load balancer.
Graceful degradation — the one gate
Whether the cache is "on" comes down entirely to isCacheEnabled(), i.e. whether
VALKEY_URL is set. Everything downstream is a consequence:
So there are really three states, and only one of them is "fast":
VALKEY_URL | Valkey reachable | Behaviour |
|---|---|---|
| unset | — | disabled: get→null, set→no-op. App runs uncached. |
| set | yes | enabled: real reads/writes with TTLs. The fast path. |
| set | no | degraded: every op is a miss + a logged warning. Slower, not broken. |
The crucial property is that the bottom two rows are the same to your code. You never
write if (cache is up) — you call cacheGet, and a null means "compute it the slow
way," whether that's because there's no Valkey, Valkey is down, or the key genuinely isn't
cached. This is the identical graceful-degradation contract the rest of the stack follows
(jobs off → inline, realtime off → no push); the cache's flavour is
"off → always miss."
The browser stub
fusion-cache is server-only — it holds a live Valkey socket, which has no business in
a browser bundle. The package's "browser" export condition resolves to a stub so an
accidental client import fails loudly instead of trying to ship iovalkey to the browser:
/**
* Browser-side stub for @tikab-interactive/fusion-cache. The real implementation holds a Valkey
* connection — server-only. Reads degrade to misses so isomorphic code can
* call them harmlessly; anything stateful throws.
*/
function serverOnly(): never {
throw new Error(
"@tikab-interactive/fusion-cache is server-only. Call it from a createServerFn handler or a job.",
);
}
export function isCacheEnabled(): boolean {
return false;
}
export function cacheGet<T>(_key: string): Promise<T | null> {
return Promise.resolve(null);
}
export const cacheSet = serverOnly;
export const cacheDelete = serverOnly;
export const cacheTtl = serverOnly;
export const getSecondaryStorage = serverOnly;The asymmetry is deliberate and matches the realtime stub: the read path
(isCacheEnabled → false, cacheGet → null) is harmless, so isomorphic code that
happens to call it on the client just sees a permanent miss. Anything that would need a
real connection (cacheSet, cacheTtl, getSecondaryStorage) throws with a message
telling you to move the call into a server function or a job. You physically cannot leak
the cache backend into client code.
How the example uses it
The cache lives behind a server function like every other server-side
capability. example/src/lib/cache-server.ts exposes two: a status check and a
write-then-read round-trip. It's a compact tour of the whole API:
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { cacheGet, cacheSet, cacheTtl, isCacheEnabled } from "@tikab-interactive/fusion-cache";
import { getSession } from "#/lib/auth";
/**
* Return type for cacheRoundtrip. Discriminated on `enabled` so the client
* can branch without unsafe casts.
*/
export type CacheRoundtripResult =
| { enabled: false }
| {
enabled: true;
stored: { value: string; at: string } | null;
ttl: number | null;
};
/** Check whether Valkey is wired up (VALKEY_URL is set). */
export const getCacheStatus = createServerFn({ method: "GET" }).handler(
async (): Promise<{ enabled: boolean }> => {
const session = await getSession();
if (!session) throw new Response("Unauthorized", { status: 401 });
// Warm the lazy Valkey connection on page load. fusion-cache connects
// lazily with no offline queue (fail-fast by design), so the very first
// command after a cold start is rejected while the socket is still
// connecting. Touching it here means the user's first round-trip click
// lands on a live connection. The result is discarded — this only
// triggers connect().
if (isCacheEnabled()) await cacheGet("__warmup__");
return { enabled: isCacheEnabled() } as const;
},
);
/** Write a key/value pair, read it back, and return the stored object + TTL. */
export const cacheRoundtrip = createServerFn({ method: "POST" })
.inputValidator(
z.object({
key: z.string().min(1).max(100),
value: z.string().max(500),
}).parse,
)
.handler(async ({ data }): Promise<CacheRoundtripResult> => {
const session = await getSession();
if (!session) throw new Response("Unauthorized", { status: 401 });
if (!isCacheEnabled()) return { enabled: false } as const;
const payload = { value: data.value, at: new Date().toISOString() };
await cacheSet(data.key, payload, 60);
const stored = await cacheGet<typeof payload>(data.key);
const ttl = await cacheTtl(data.key);
return { enabled: true, stored, ttl } as const;
});Three things in there are the lessons:
- The discriminated union return (
{ enabled: false } | { enabled: true; … }) makes the two states impossible to confuse on the client — the page can't try to render a TTL that doesn't exist. getCacheStatuswarms the connection. Because oflazyConnect+enableOfflineQueue: false, the first command after a cold start can be rejected mid-connect. Firing a throwawaycacheGet("__warmup__")on page load means the user's first real round-trip lands on an established socket. This is the price of fail-fast, paid deliberately and in one place.- Auth-gated. Both functions check
getSession()first — the cache isn't a public endpoint.
The sandbox page (example/src/routes/sandbox.cache.tsx) is the UI: it checks status on
mount, shows an enabled / disabled badge, and on the disabled branch renders setup hints
instead of erroring. (Shown inline here rather than as a physical include — the docs build
can't resolve include paths containing sandbox.)
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import {
Alert,
Badge,
Button,
Code,
Group,
Stack,
Text,
TextInput,
} from "@tikab-interactive/fusion-ui/mantine";
import { PageHeader, testIds } from "@tikab-interactive/fusion-ui";
import type { CacheRoundtripResult } from "#/lib/cache-server";
import { cacheRoundtrip, getCacheStatus } from "#/lib/cache-server";
import { m } from "#/paraglide/messages.js";
// Cache sandbox: set a key in Valkey via fusion-cache, read it back, show the
// stored JSON and remaining TTL. When VALKEY_URL is not set the server fn
// returns { enabled: false } and we show setup instructions instead of erroring.
export const Route = createFileRoute("/sandbox/cache")({
component: CacheSandboxPage,
});
function CacheSandboxPage() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [key, setKey] = useState("sandbox:greeting");
const [value, setValue] = useState("hello from fusion-cache");
const [result, setResult] = useState<CacheRoundtripResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Check status on mount (once).
useState(() => {
getCacheStatus()
.then(({ enabled: e }) => setEnabled(e))
.catch(() => setEnabled(false));
});
async function runRoundtrip() {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await cacheRoundtrip({ data: { key, value } });
setResult(res);
setEnabled(res.enabled);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}
// …badge (enabled / disabled / checking), key + value inputs, a "run" button,
// and a <Code> block showing result.stored + remaining TTL. The full JSX is in
// example/src/routes/sandbox.cache.tsx.
return null;
}Point it at a running Valkey and the round-trip shows the stored JSON plus a counting-down
TTL; leave VALKEY_URL unset and the badge reads "disabled" and the page calmly explains
how to turn it on.
Running it locally
The example's docker-compose.yml ships a Valkey service, so it's two steps:
docker compose up -d valkey # starts valkey/valkey:8-alpine on :6379# .env.local
VALKEY_URL=redis://localhost:6379Restart the dev server so fusion-env re-reads the variable, and the
sandbox badge flips to enabled. Unset it again and everything still works — you've just
turned the cache back off. See Deploy for the production wiring (a managed Valkey
/ Redis and the same single VALKEY_URL).
Where to go next
- Configuration — the
VALKEY_URLgate is one entry in the typed env schema; howfusion-envvalidates it and why every backend var is optional. - Authorization — Better Auth and the
secondaryStorageseam thatgetSecondaryStorage()fills when you go multi-instance. - Realtime — the sibling package with the identical degradation contract (configured → works, unset → no-op), and the original home of the cache blurb.
- Conventions — the graceful-degradation pattern (
is…Enabled()+ safe fallback) shared by every optionalfusion-*package.