Skip to content

Caching ​

Keryx already boots a Redis connection for you. api.redis.redis is a fully configured ioredis client, available the moment your initializers finish starting. You don't need a separate caching library. Redis is your caching layer.

How Redis is Initialized ​

The redis initializer creates two ioredis connections at startup:

ConnectionAccessPurpose
api.redis.redisGeneral commandsCaching, ad-hoc queries, anything your app needs
api.redis.subscriptionPubSub subscriberReserved for channels — don't use this for caching

Both connect to the URL in config.redis.connectionString (defaults to redis://localhost:6379/0, overridable via REDIS_URL). The initializer verifies connectivity at boot — if Redis is unreachable, the server won't start.

Because api.redis.redis is a standard ioredis instance, every Redis command is available: GET, SET, DEL, HSET, LPUSH, EXPIRE, SETNX, pipelines, Lua scripts, streams — the full Redis API. No wrapper to learn.

Cache-Aside Pattern ​

The most common approach: check Redis before hitting the database, populate the cache on a miss.

ts
import { api } from "keryx";
import { eq } from "drizzle-orm";
import { users } from "../schema";

const CACHE_TTL = 300; // 5 minutes

export async function getUserById(id: number) {
  const cacheKey = `cache:user:${id}`;

  // Try cache first
  const cached = await api.redis.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Miss — query the database
  const [user] = await api.db.db
    .select()
    .from(users)
    .where(eq(users.id, id));

  if (user) {
    await api.redis.redis.set(cacheKey, JSON.stringify(user), "EX", CACHE_TTL);
  }

  return user ?? null;
}

Invalidate when the data changes:

ts
export async function updateUser(id: number, data: Partial<User>) {
  const [updated] = await api.db.db
    .update(users)
    .set(data)
    .where(eq(users.id, id))
    .returning();

  await api.redis.redis.del(`cache:user:${id}`);
  return updated;
}

Cache Helper ​

Wrap the cache-aside pattern into a reusable helper so your actions stay concise:

ts
import { api } from "keryx";

export async function cached<T>(
  key: string,
  ttl: number,
  fn: () => Promise<T>,
): Promise<T> {
  const hit = await api.redis.redis.get(key);
  if (hit) return JSON.parse(hit) as T;

  const result = await fn();
  await api.redis.redis.set(key, JSON.stringify(result), "EX", ttl);
  return result;
}

Use it in read-heavy actions:

ts
export class UserShow implements Action {
  name = "user:show";
  web = { route: "/user/:userId", method: HTTP_METHOD.GET };
  inputs = z.object({ userId: z.coerce.number() });

  async run(params: ActionParams<UserShow>) {
    const user = await cached(
      `cache:user:${params.userId}`,
      60,
      () => UserOps.findById(params.userId),
    );
    return { user };
  }
}

The helper checks Redis first, calls your function on a miss, and stores the result with a TTL — all in one call. Because the caching happens inside run(), the action controls exactly what gets cached and how the key is built.

Invalidation ​

By Key ​

When you know exactly which cache entry changed:

ts
await api.redis.redis.del(`cache:user:${userId}`);

By Pattern ​

Need to bust all cached responses for a resource? Use a key prefix convention and scanStream:

ts
export async function invalidateUserCache(userId: number) {
  const stream = api.redis.redis.scanStream({
    match: `cache:*:*user*:${userId}*`,
    count: 100,
  });

  for await (const keys of stream) {
    if (keys.length > 0) await api.redis.redis.del(...keys);
  }
}

Version-Based ​

For high-throughput invalidation, embed a version counter in your cache keys and bump it instead of scanning. A single INCR replaces an unbounded SCAN:

ts
const version = await api.redis.redis.get(`version:user:${id}`) ?? "0";
const cacheKey = `cache:user:${id}:v${version}`;

// To invalidate: bump the version. Old keys expire naturally via TTL.
await api.redis.redis.incr(`version:user:${id}`);

Beyond GET/SET ​

Because you have the full ioredis client, you're not limited to string caching:

TechniqueRedis commandUse case
Atomic set-if-absentSETNX / SET ... NXDistributed locks, deduplication
Hash fieldsHSET / HGET / HGETALLCache objects without serializing the whole thing
Sorted setsZADD / ZRANGELeaderboards, rate-limit sliding windows
Pipelinespipeline().get().set().exec()Batch multiple commands in one round trip
Lua scriptseval() / evalsha()Atomic read-modify-write operations
ExpiryEXPIRE / PEXPIRETTL on any key type, not just strings

Why Not a Cache Abstraction? ​

You might expect a cache.get() / cache.set() wrapper. We intentionally skip that layer — ioredis already has a clean API, and a thin wrapper just hides the Redis features you'll eventually need. If you know Redis, you already know the Keryx caching API.

Released under the MIT License.