Skip to content
Fusion

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 / cacheSet are cache.get / cache.set; VALKEY_URL is pointing CACHES["default"] at django_redis.cache.RedisCache. And like Django falling back to LocMemCache (or just always-miss) when you don't configure a backend, the app here runs fine with no VALKEY_URL at all — every cacheGet simply returns null and every cacheSet is 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().

FunctionDoesWhen disabled / unreachable
isCacheEnabled()Boolean(process.env.VALKEY_URL)returns false
cacheGet<T>(key)read + JSON.parse a valuereturns null (a miss)
cacheSet(key, val, ttl)JSON.stringify + write with a TTL (seconds)no-op
cacheDelete(key)drop a keyno-op
cacheTtl(key)remaining TTL in secondsreturns null
getSecondaryStorage()a { get, set, delete } string store for Better Authreturns null

Two design rules run through the whole package and explain almost every line:

  1. Every function checks isCacheEnabled() first. No URL, no work — it returns the "absent" value before touching the network.
  2. Every Valkey call is wrapped in try/catch that 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 the try/catch in 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_redis defaults to the opposite — it'll raise on a dead backend unless you set IGNORE_EXCEPTIONS. fusion-cache bakes 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:

fusion-cache/src/index.ts
/** 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:

fusion-cache/src/index.ts
/**
 * 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? secondaryStorage is the same idea as backing Django's rate limiting or session store with Redis so several gunicorn workers 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:

Loading diagram...

So there are really three states, and only one of them is "fast":

VALKEY_URLValkey reachableBehaviour
unsetdisabled: getnull, set→no-op. App runs uncached.
setyesenabled: real reads/writes with TTLs. The fast path.
setnodegraded: 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:

fusion-cache/src/browser-stub.ts
/**
 * 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 (isCacheEnabledfalse, cacheGetnull) 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:

example/src/lib/cache-server.ts
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.
  • getCacheStatus warms the connection. Because of lazyConnect + enableOfflineQueue: false, the first command after a cold start can be rejected mid-connect. Firing a throwaway cacheGet("__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:6379

Restart 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_URL gate is one entry in the typed env schema; how fusion-env validates it and why every backend var is optional.
  • Authorization — Better Auth and the secondaryStorage seam that getSecondaryStorage() 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 optional fusion-* package.