Redis
Add a shared Redis cache to speed up your app and scale it across multiple instances.
VitNode can use Redis as a shared cache. Once configured,
it is exposed on every request as c.get("cache"), speeds up authentication by
caching session lookups, and backs the
rate limiter so limits hold up when you run
several instances behind a load balancer.
Redis is optional. Without it, the cache safely degrades to no-ops (reads
return null, remember just runs its loader) and the rate limiter falls back
to in-memory storage. A Redis outage at runtime never breaks a request.
Setup
Start Redis
The development docker-compose.yml ships a cache service
(redis:8.8-alpine). Start it (alongside the database) with:
bun docker:devpnpm docker:devnpm run docker:devSet the connection URL
Add REDIS_URL and REDIS_PASSWORD to your .env file. REDIS_PASSWORD is the
single source of truth for the password — the Docker container requires it and
the app authenticates with it — so the URL itself carries no credentials:
REDIS_URL=redis://localhost:6379
# Password the Docker Redis container requires (and the app authenticates with)
REDIS_PASSWORD=rootChange the password in production
root is a convenience default for local development. Set a strong
REDIS_PASSWORD in production.
Configure it
Pass the redis object to buildApiConfig. Redis is opt-in — wire it up so
it only turns on when REDIS_URL is set, so the app runs fine without Redis in
environments where you don't need it:
export const vitNodeApiConfig = buildApiConfig({
redis: process.env.REDIS_URL
? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD }
: undefined,
});No REDIS_URL, no Redis
When REDIS_URL is unset, redis is undefined and VitNode skips Redis
entirely: the cache is a no-op, the rate limiter uses in-memory storage, and
WebSockets run in single-instance mode. Nothing connects to Redis.
Options
The redis object accepts a url plus any
ioredis RedisOptions.
Prop
Type
Using the cache
Access the cache in any route or model with c.get("cache").
export const postsRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {},
handler: async c => {
const cache = c.get("cache");
await cache.set("hello", { world: true }, 60); // expires in 60s
const value = await cache.get<{ world: boolean }>("hello");
return c.json(value);
},
});Keys are namespaced per plugin
You pass a short key like hello; VitNode automatically stores it under your
plugin's code — vitnode:cache:{plugin_code}:hello. So two plugins can both
use a hello key without clashing, and you never read another plugin's data
by accident. The plugin code is taken from the request context, so there is
nothing to prefix by hand.
Methods
Prop
Type
Caching database queries
The most useful method is remember — it runs your loader only on a cache
miss, which is perfect for hot, rarely-changing data. Good candidates are
reference data such as role-name translations or resolved staff permissions.
const roleNames = await c.get("cache").remember(
`role-names:${roleId}`,
// Cache for 5 minutes
60 * 5,
() => resolveRoleNames(c, [roleId]),
);Remember to invalidate
A cache is only correct if you clear stale entries. When the underlying data changes (e.g. an admin edits a role), delete the affected key so the next read reloads it:
await c.get("cache").delete(`role-names:${roleId}`);Prefer a short TTL for data you cannot reliably invalidate. null results are
never cached, so a loader that returns null runs every time.
Session caching
Every authenticated request has to resolve the current user from the session cookie — a session lookup plus a user lookup in the database. On a single page load that can be many requests, and therefore many repeated queries.
When redis is configured, VitNode caches that resolution for you — there is
nothing extra to enable. Subsequent requests read the user straight from Redis
instead of hitting the database. This applies to both regular and admin
sessions.
The cached entry:
- is scoped to the session's device, so it is never served to a different device;
- lives for at most 60 seconds, and never longer than the session's remaining lifetime, so an expired session is never served from cache;
- is cleared the instant the user signs out.
Admin access is still re-checked against the database on every request, so revoking an admin takes effect immediately — not when the cache expires.
Safe without Redis
Without Redis — or if it becomes unreachable — every request falls back to the database exactly as before. Session caching only ever removes database load; it never changes what a request can see beyond the short TTL above.
Rate limiting with Redis
When redis is configured, the rate limiter
automatically uses it as its store, so counters are shared across all
instances. If Redis becomes unavailable, an in-memory insurance limiter keeps
rate limiting working. There is nothing extra to enable.
Scaling WebSockets
The WebSocket connection registry lives in memory on each
server process. So if you run several instances behind a load balancer, a
broadcast or sendToUser would, on its own, only reach the clients connected
to the same instance.
When redis is configured, VitNode bridges the instances for you — there is
nothing extra to enable. broadcast and sendToUser reach every matching
client, no matter which instance they are connected to.
How it works
Each instance keeps delivering to its own connected clients instantly, then
publishes the message to a shared Redis pub/sub channel (vitnode:ws). Every
other instance is subscribed, receives the message, and delivers it to its own
clients:
realtime.broadcast(channel, data) ← called on Instance A
│
├─ 1. deliver to Instance A's own clients (instant, local)
│
└─ 2. PUBLISH { id, data, origin: A } ─► Redis channel "vitnode:ws"
│
▼ (all instances subscribe)
Instance A ── ignores its own echo (origin = A)
Instance B ── 3. deliver to Instance B's clientsEach message is tagged with the sending process's id, so an instance ignores the echo of its own messages (no double delivery).
Requirements
All instances must point at the same Redis. If Redis is unreachable, realtime automatically falls back to single-instance delivery (local clients still receive messages). This bridges message delivery only — each instance still holds its own socket connections in memory.