Cache & Background Jobs
Bot modules (and all extension types) have access to two complementary APIs for performance optimization: ctx.cache for fast Redis-backed temporary storage, and ctx.defer() for queuing background work after the handler returns.
Each API works independently. You can use cache without defer, defer without cache, or combine them for patterns like cache-first reads with background DB persistence.
ctx.cache — Scoped Redis cache
A key-value cache backed by Redis, scoped to the extension installation. Provides sub-millisecond reads and writes without the overhead of a database round-trip.
API
ctx.cache = {
get: (key: string) => Promise<unknown | null>,
set: (key: string, value: unknown, ttl?: number) => Promise<void>,
delete: (key: string) => Promise<void>,
increment: (key: string, by?: number) => Promise<number>,
};
Basic usage
import { command } from "@zaflun/lumio-sdk/server";
export const counter = command("count", async (ctx) => {
// Increment a counter (creates the key with value 0 if it does not exist)
const count = await ctx.cache.increment("message_count", 1);
return { reply: `Message count: ${count}` };
});
Setting values with TTL
// Store a value with a 5-minute TTL
await ctx.cache.set("last_clipper", ctx.user.name, 300);
// Store a value with the default TTL (1 hour)
await ctx.cache.set("status", "active");
// Read it back
const status = await ctx.cache.get("status"); // "active" or null if expired
Deleting values
await ctx.cache.delete("spam_score:user123");
Key scoping
Your extension only provides the key name. The Rust Worker automatically prepends ext-cache:\{install_id\}: before executing any Redis command:
Extension calls: ctx.cache.set("score:user123", 42, 3600)
Redis executes: SET ext-cache:{install_id}:score:user123 42 EX 3600
You cannot access other extensions' cache keys or any internal Lumio Redis keys.
Key format rules
Keys must match the pattern ^[a-zA-Z0-9:_-]+$ — alphanumeric characters plus :, _, and -. This validation is enforced in Rust (not JavaScript), so it cannot be bypassed.
Limits
| Limit | Value |
|---|---|
| Max TTL | 24 hours (86,400 seconds) |
| Default TTL | 1 hour (3,600 seconds) |
| Max key length | 128 characters |
| Max value size | 64 KB |
| Max keys per install | 1,000 |
| Operations rate limit | 200 ops/min per install |
| No-TTL behavior | Keys without explicit TTL get the 1-hour default |
There are no wildcard operations (KEYS, SCAN). Extensions cannot enumerate their own keys.
When to use cache vs database
| Use case | Use ctx.cache | Use ctx.db |
|---|---|---|
| Cooldown counters | Yes | No |
| Spam scores | Yes | No |
| "Last chatter" queue | Yes | No |
| Temporary vote counts | Yes | No |
| User point balances (fast reads) | Yes (with DB backup) | Yes (source of truth) |
| Leaderboards | No | Yes |
| Permanent user data | No | Yes |
| Historical logs | No | Yes |
ctx.defer() — Background jobs
Queues a function that the Worker executes after the handler returns. The handler can respond to the user immediately while expensive work runs in the background — outside the 500ms sync deadline.
API
ctx.defer(fn: () => Promise<void>): void
Basic usage
import { command } from "@zaflun/lumio-sdk/server";
export const points = command("points", async (ctx, args) => {
const balance = 100; // fast calculation
// Reply immediately
// Background work runs after this return
ctx.defer(async () => {
// This runs after the reply is sent
await ctx.db.insert("point_history", {
user_id: ctx.user.id,
action: "check",
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name}: ${balance} points` };
});
Limits
| Limit | Value |
|---|---|
| Max deferred calls per handler | 5 |
| Timeout per deferred call | 10 seconds |
| Available APIs | ctx.db, ctx.cache, ctx.fetch, ctx.secrets |
| Error handling | Logged to Sentry, not surfaced to the chat user |
Multiple deferred calls
You can queue up to 5 deferred calls per handler invocation. They execute sequentially after the handler returns:
export const gamble = command("gamble", async (ctx, args) => {
const result = Math.random() > 0.5 ? "win" : "lose";
ctx.defer(async () => {
await ctx.db.patch("user_points", ctx.user.id, {
balance: result === "win" ? 200 : 50,
});
});
ctx.defer(async () => {
await ctx.db.insert("gamble_history", {
user_id: ctx.user.id,
result,
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name} ${result === "win" ? "won" : "lost"}!` };
});
Error handling
If a deferred call throws an exception or times out, the error is logged but does not affect the user. The chat reply has already been sent. Errors from deferred calls appear in the developer dashboard error reports and lumio logs.
Combined pattern: cache-first with background persist
The most common pattern combines ctx.cache for fast reads with ctx.defer() for background database writes:
import { command } from "@zaflun/lumio-sdk/server";
export const points = command("points", async (ctx, args) => {
// Fast: read from cache (~1ms)
const cached = (await ctx.cache.get(`pts:${ctx.user.id}`)) as number | null;
const balance = cached ?? 0;
const newBalance = balance + 1;
// Fast: write to cache (~1ms)
await ctx.cache.set(`pts:${ctx.user.id}`, newBalance, 3600);
// Slow: persist to DB in background (~5-20ms, runs after reply)
ctx.defer(async () => {
await ctx.db.patch("user_points", ctx.user.id, { balance: newBalance });
await ctx.db.insert("point_history", {
user_id: ctx.user.id,
amount: 1,
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name}: ${newBalance} points` };
});
This pattern keeps the handler response under the 500ms sync deadline while ensuring data eventually reaches the database.
Comparison
ctx.cache | ctx.db | ctx.defer() | |
|---|---|---|---|
| Backend | Redis | PostgreSQL | V8 (background) |
| Persistence | Temporary (TTL) | Permanent | N/A (orchestrator) |
| Latency | ~1ms | ~5-20ms | Async (after return) |
| Use case | Counters, cooldowns | User data, leaderboards | Background jobs |