Skip to content

Cache plugin

@warden/cache adds a caching layer to your Warden bot with a clean, injectable API and swappable storage backends. It ships with three built-in adapters — Redis, in-memory, and database — so you can pick the right backend for your environment without changing any application code.

Caching is invaluable for any bot with repeated data access. Guild settings that rarely change, user warning counts that get checked on every message, case lookups that moderators run repeatedly — all of these benefit enormously from a fast cache layer instead of hitting the database on every request.

Terminal window
pnpm add @warden/cache

If you’re using the Redis adapter (the default), you’ll also need a Redis server running. The scaffolder’s docker-compose.yml includes one, or you can use any Redis-compatible service.

The peer dependencies are:

PackageDescription
@warden/coreWarden framework (you already have this)

The Redis client is bundled with the package, so there’s nothing extra to install for the default adapter.

Register CacheServiceProvider in your bootstrap file:

import {Bot} from '@warden/core';
import {CacheServiceProvider} from '@warden/cache';
new Bot()
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts'))
.intents(i => i.defaults())
.partials(p => p.defaults())
.plugins([CacheServiceProvider])
.start();

With no config/cache.ts, the plugin defaults to a lazy Redis adapter reading REDIS_URL from the environment:

.env
REDIS_URL=redis://127.0.0.1:6379

To customise anything — pick a different adapter, pass an explicit URL — write a config/cache.ts:

src/config/cache.ts
import {Config, env} from '@warden/core';
import {CacheServiceProvider, LazyRedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, {
adapter: new LazyRedisAdapter({url: env.required('REDIS_URL')}),
});

The plugin connects to Redis lazily (on first use) and disconnects during shutdown().

Pick a different adapter in config/cache.ts to swap storage backends without changing any application code:

The default. Production-grade, persistent across restarts, shared across instances.

config/cache.ts
import {Config, env} from '@warden/core';
import {CacheServiceProvider, LazyRedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, {
adapter: new LazyRedisAdapter({url: env.required('REDIS_URL')}),
});
AdapterPersistentShared across instancesExternal dependency
LazyRedisAdapterYesYesRedis server
MemoryAdapterNoNoNone
database: true (drizzle-backed)YesYes@warden/drizzle + applied migration

Inject CacheService into any command, event handler, or service class. The API is intentionally small and straightforward:

import {CacheService} from '@warden/cache';
// Store a value (TTL in seconds)
await cache.set('guild:123:settings', settings, 300);
// Retrieve a value
const settings = await cache.get('guild:123:settings');
// Check if a key exists
const exists = await cache.has('guild:123:settings');
// Remove a key
await cache.forget('guild:123:settings');
// Get or set — returns cached value if it exists, otherwise calls the
// callback, caches the result, and returns it
const settings = await cache.remember('guild:123:settings', 300, async () => {
return await this.settingsService.fetchFromDatabase('123');
});

Here’s the full method reference:

MethodSignatureDescription
getget<T>(key: string): Promise<T | null>Retrieve a cached value, or null if not found
setset<T>(key: string, value: T, ttl?: number): Promise<void>Store a value with an optional TTL in seconds
hashas(key: string): Promise<boolean>Check whether a key exists in the cache
forgetforget(key: string): Promise<void>Remove a key from the cache
rememberremember<T>(key: string, ttl: number, cb: () => Promise<T>): Promise<T>Get the cached value or execute the callback, cache the result, and return it

The remember() method deserves special attention because it’s by far the most useful method for day-to-day work. It implements the “cache-aside” pattern in a single call: check the cache, return if hit, otherwise fetch from the source, cache it, and return.

@command({parent: 'mod', name: 'warn', description: 'Warn a user'})
export default class ModWarnCommand extends Subcommand {
constructor(private cache: CacheService, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
// Only hits the database if the count isn't cached
const warningCount = await this.cache.remember(
`warnings:${interaction.guildId}:${target.id}`,
60, // cache for 1 minute
() => this.caseService.getWarningCount(interaction.guildId!, target.id),
);
// ... check threshold, issue warning, etc.
}
}

Of course, when you create a new warning, you’ll want to invalidate the cache so the count is fresh:

await this.caseService.createWarning(guildId, targetId, reason, moderatorId);
await this.cache.forget(`warnings:${guildId}:${targetId}`);

The cache plugin uses V8 serialization (v8.serialize() / v8.deserialize()) to store values. This means you can cache more than just strings and numbers — objects, arrays, dates, Maps, Sets, and other structured data all work out of the box:

// All of these work
await cache.set('simple', 'hello');
await cache.set('number', 42);
await cache.set('object', {guildId: '123', logChannel: '456', maxWarnings: 3});
await cache.set('date', new Date());
await cache.set('array', [{id: 1, type: 'warning'}, {id: 2, type: 'mute'}]);

V8 serialization is significantly faster than JSON.stringify() / JSON.parse() for complex objects, and it supports types that JSON doesn’t (like Date, Map, Set, RegExp, Buffer, and undefined).

One of the nicest features of the cache plugin is that it integrates transparently with Warden’s cooldown system. When @warden/cache is installed and registered, the @cooldown() decorator automatically switches from in-memory storage to Redis-backed storage.

This means:

  • Cooldowns survive bot restarts. If a user hit the /warn cooldown and you restart the bot, they’re still on cooldown when it comes back up.
  • Cooldowns work across instances. If you’re running multiple bot processes behind a gateway, cooldown state is shared through Redis.

You don’t need to change any of your @cooldown() decorators — the framework detects the cache plugin and uses it automatically. Your existing cooldown code works identically with either backend; the only difference is persistence.

// This works with in-memory storage AND Redis — no code changes needed
@command({name: 'warn', description: 'Warn a user'})
@cooldown({duration: 10, scope: 'user'})
export default class WarnCommand implements Command {
// ...
}

The cache plugin reads from the cache config namespace:

src/config/cache.ts
import {env} from '@warden/core';
export default {
host: env('REDIS_HOST', '127.0.0.1'),
port: env.number('REDIS_PORT', 6379),
password: env('REDIS_PASSWORD'),
prefix: env('CACHE_PREFIX', 'modbot:'),
db: env.number('REDIS_DB', 0),
};

The prefix option is particularly useful if you’re sharing a Redis instance across multiple bots or applications. Every key the cache plugin writes is prefixed with this value, so cache.set('foo', 'bar') actually stores modbot:foo in Redis.

Guild settings rarely change but are read on almost every interaction. A perfect candidate for caching:

import {singleton} from 'tsyringe';
@singleton()
export class SettingsService {
constructor(private cache: CacheService, private db: DrizzleService) {}
async getGuildSettings(guildId: string) {
return this.cache.remember(`guild:${guildId}:settings`, 300, async () => {
return this.db.query
.select()
.from(guildSettings)
.where(eq(guildSettings.guildId, guildId))
.then(rows => rows[0] ?? this.getDefaults());
});
}
async updateGuildSettings(guildId: string, data: Partial<GuildSettings>) {
await this.db.query
.update(guildSettings)
.set(data)
.where(eq(guildSettings.guildId, guildId));
// Invalidate the cache so the next read picks up the changes
await this.cache.forget(`guild:${guildId}:settings`);
}
private getDefaults(): GuildSettings {
return {logChannel: null, muteRole: null, maxWarnings: 3};
}
}

When a moderator runs /mod history, you can cache the result so repeated lookups are instant:

@command({parent: 'mod', name: 'history', description: 'View moderation history'})
export default class ModHistoryCommand extends Subcommand {
constructor(private cache: CacheService, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const modCases = await this.cache.remember(
`cases:${interaction.guildId}:${target.id}`,
120, // 2 minutes
() => this.caseService.getHistory(interaction.guildId!, target.id),
);
// ... format and display cases
}
}