Advanced

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:dev
pnpm docker:dev
npm run docker:dev

Set 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:

.env
REDIS_URL=redis://localhost:6379

# Password the Docker Redis container requires (and the app authenticates with)
REDIS_PASSWORD=root

Change 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:

src/vitnode.api.config.ts
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").

plugins/{plugin_name}/src/routes/posts.route.ts
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.

Cache an expensive query
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 clients

Each 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.

On this page