Skip to content

Best Practices

Guidelines and recommendations for using @warlock.js/cache effectively.

Always use remember() for database queries, API calls, or expensive computations to prevent cache stampedes. 0, false and "" are cached like any other value; only null counts as a miss:

// ✅ GOOD: Prevents stampedes
const posts = await cache.remember("posts.popular", 3600, async () => {
return await db.query("SELECT * FROM posts ORDER BY views DESC LIMIT 10");
});
// ❌ BAD: Vulnerable to stampedes
let posts = await cache.get("posts.popular");
if (!posts) {
posts = await db.query("SELECT * FROM posts ORDER BY views DESC LIMIT 10");
await cache.set("posts.popular", posts, 3600);
}

Group related cache entries with tags for easy invalidation:

// ✅ GOOD: Use tags for related data
const tagged = cache.tags([`user.${userId}`, "users"]);
await tagged.set(`user.${userId}.profile`, profileData);
await tagged.set(`user.${userId}.posts`, postsData);
// Invalidate all user data at once
await cache.tags([`user.${userId}`]).invalidate();
// ❌ BAD: Manual key tracking
await cache.set(`user.${userId}.profile`, profileData);
await cache.set(`user.${userId}.posts`, postsData);
// Later: remember all keys to invalidate?

Choose TTLs based on data freshness requirements:

import { CACHE_FOR } from "@warlock.js/cache";
// Frequently changing data: Short TTL
await cache.set("current.stats", stats, CACHE_FOR.HALF_HOUR);
// Semi-static data: Medium TTL
await cache.set("user.profile", profile, CACHE_FOR.ONE_DAY);
// Rarely changing data: Long TTL
await cache.set("app.config", config, CACHE_FOR.ONE_WEEK);

Always use increment() / decrement() for counters instead of manual get/set:

// ✅ GOOD: atomic, race-condition free
await cache.increment("page.views", 1);
// ❌ BAD: race condition risk
const views = (await cache.get("page.views")) || 0;
await cache.set("page.views", views + 1);

For structured (non-numeric) values that need atomic mutation, use update() or merge():

// ✅ Atomic partial update on a cached object
await cache.merge<User>(`user.${id}`, { lastSeenAt: Date.now() });
// ✅ Full read-modify-write with the current value
await cache.update<UserState>(`user.${id}.state`, (current) => ({
...(current ?? defaultState),
visitCount: (current?.visitCount ?? 0) + 1,
}));

For “set only if missing” (distributed locks, idempotency keys), use onConflict: "create":

// ✅ Portable distributed lock
const lock = await cache.set(`lock.${job.id}`, process.pid, {
onConflict: "create",
ttl: "5m",
});
if (!lock.wasSet) throw new Error("Another worker holds the lock");

See Update & Merge and Set Options for details.

Set up event listeners for monitoring and debugging:

// ✅ GOOD: Monitor cache performance
cache.on("hit", () => metrics.increment("cache.hit"));
cache.on("miss", () => metrics.increment("cache.miss"));
cache.on("error", ({ error }) => logger.error("Cache error", error));

Organize cache keys hierarchically with namespaces:

// ✅ GOOD: Organized with namespaces
await cache.set("users.profile.123", profileData);
await cache.set("users.settings.123", settingsData);
await cache.removeNamespace("users"); // Clears all user data
// ❌ BAD: Flat keys, hard to manage
await cache.set("user_123_profile", profileData);
await cache.set("user_123_settings", settingsData);

Use maxSize for memory driver to prevent unbounded growth:

// ✅ GOOD: Prevents memory leaks
cache.setCacheConfigurations({
drivers: { memory: MemoryCacheDriver },
options: {
memory: {
maxSize: 1000, // Auto-evicts when full
ttl: 3600,
},
},
});

Always use the Redis (or Postgres) driver in production with multiple servers. The memory drivers are per-process; see Running on multiple servers:

// ✅ GOOD: Shared cache across servers
cache.setCacheConfigurations({
default: "redis",
drivers: { redis: RedisCacheDriver },
options: {
redis: {
host: process.env.REDIS_HOST,
port: 6379,
},
},
});

Only cache data that benefits from caching:

// ❌ BAD: Caching already fast operations
await cache.set("simple.calculation", 1 + 1, 3600);
// ✅ GOOD: Cache expensive operations
await cache.remember("expensive.query", 3600, async () => {
return await db.complexAggregation();
});

Avoid forever() unless data truly never changes:

// ❌ BAD: Infinite cache for changing data
await cache.forever("user.stats", stats); // Stats change!
// ✅ GOOD: Reasonable TTL
await cache.set("user.stats", stats, CACHE_FOR.ONE_HOUR);
// ✅ GOOD: Forever for truly static data
await cache.forever("app.version", "1.0.0");

Set maxSize for memory driver in production:

// ❌ BAD: Unbounded memory growth
cache.setCacheConfigurations({
options: {
memory: {
// No maxSize - can grow infinitely!
},
},
});
// ✅ GOOD: Bounded memory
cache.setCacheConfigurations({
options: {
memory: {
maxSize: 1000, // Prevents memory leaks
},
},
});

Don’t Cache User-Specific Data in Shared Drivers

Section titled “Don’t Cache User-Specific Data in Shared Drivers”

Be careful with user-specific data in Redis:

// ⚠️ CAUTION: User-specific data in shared Redis
await cache.set(`user.${userId}.session`, sessionData);
// ✅ GOOD: Use global prefix or namespaces
// Configuration:
options: {
redis: {
globalPrefix: () => `tenant.${getTenantId()}`;
}
}

Don’t Use File Driver in Distributed Setup

Section titled “Don’t Use File Driver in Distributed Setup”

File driver doesn’t work across multiple servers:

// ❌ BAD: File driver for distributed app
cache.setCacheConfigurations({
default: "file", // Each server has its own cache!
});
// ✅ GOOD: Redis for distributed
cache.setCacheConfigurations({
default: "redis", // Shared across all servers
});

When more than one process serves traffic, every process gets its own memory, memoryExtended, lru and file cache. Use redis or pg for anything that must agree across servers.

NeedUseWhy
Rate-limit or login-throttle countersincrement() on redis or pgINCRBY / a single upsert, TTL kept; on memory each server counts separately, so the limit is N× looser
One-time tokenspull() on redis or pgGETDEL / DELETE … RETURNING hands the value to one caller
Shared read-modify-writeupdate() / merge() on redis or pgCompare-and-set with retries; keep the callback free of side effects
One server runs a jobcache.lock() on redis or pgFinite TTL required; release only removes your own lock
Tag invalidationredis or pgThe index is shared, so one server’s invalidate() reaches every server’s entries

Also remember:

  • Stampede protection is per process. remember(), swr() and cached() share one in-flight computation within a process only; N servers can still compute once each on a cold miss. Wrap the work in cache.lock() when it must run once globally.
  • Set a globalPrefix when the Redis database is shared. Without one, flush() runs FLUSHDB and clears the whole database.
  • Redis Cluster is not supported.

When you only need to check existence:

// ✅ GOOD: More efficient
if (await cache.has("key")) {
const value = await cache.get("key");
}
// ❌ BAD: Unnecessary value fetch
const value = await cache.get("key");
if (value !== null) {
// ...
}

Use many() and setMany() for multiple operations:

// ✅ GOOD: Single round-trip
const values = await cache.many(["key1", "key2", "key3"]);
// ❌ BAD: Multiple round-trips
const value1 = await cache.get("key1");
const value2 = await cache.get("key2");
const value3 = await cache.get("key3");

Primitive values skip cloning overhead:

// ✅ GOOD: Fast (no cloning)
await cache.set("count", 42);
await cache.set("flag", true);
await cache.set("name", "John");
// Objects/arrays are cloned (security), but still fast
await cache.set("user", { name: "John" });
Use CaseRecommended Driver
DevelopmentMemory
TestingNull
Single serverMemory or File
Distributed appsRedis
Persistence + you already run PostgresPg (KV mode)
Similarity / RAG / semantic cachingPg (with vector config) — production. Memory drivers — dev only.
Memory-constrainedLRU Memory
Sliding expirationMemory Extended
// User-specific + collection
cache.tags([`user.${id}`, "users"]);
// Resource-specific + collection
cache.tags([`post.${id}`, "posts", `category.${catId}`]);
// By feature
cache.tags(["dashboard", "homepage"]);
// By data type
cache.tags(["config", "settings"]);
// Multiple dimensions
cache.tags([
`user.${userId}`,
`category.${catId}`,
"featured", // if applicable
]);
let hits = 0,
misses = 0;
cache.on("hit", () => hits++);
cache.on("miss", () => misses++);
setInterval(() => {
const total = hits + misses;
const hitRate = total > 0 ? (hits / total) * 100 : 0;
metrics.set("cache.hit_rate", hitRate);
hits = misses = 0; // Reset
}, 60000);
if (process.env.DEBUG_CACHE) {
cache.on("set", ({ key, ttl }) => {
console.log(`[Cache] SET ${key} (TTL: ${ttl}s)`);
});
cache.on("miss", ({ key }) => {
console.log(`[Cache] MISS ${key}`);
});
}
cache.on("error", ({ error, driver, key }) => {
sentry.captureException(error, {
tags: {
cache_driver: driver,
cache_key: key,
},
});
});
options: {
memory: {
maxSize: 1000, // Prevents unbounded growth
}
}
cache.on("set", () => {
// Track cache size
});
cache.on("removed", () => {
// Track cache size
});

Prevent key collisions between applications:

options: {
redis: {
globalPrefix: process.env.APP_NAME || "myapp";
}
}

Keys are automatically sanitized, but be aware:

// Use dot notation directly instead of objects
await cache.set("user.1.profile", userData);
await cache.get("user.1.profile");

Avoid caching sensitive information without encryption:

// ❌ BAD: Caching sensitive data
await cache.set("user.password", password);
// ✅ GOOD: Cache only non-sensitive data
await cache.set("user.profile", {
name: user.name,
email: user.email,
// No password!
});
async function getUser(id: number) {
const cached = await cache.get(`user.${id}`);
if (cached) return cached;
const user = await db.users.findById(id);
await cache.set(`user.${id}`, user, 3600);
return user;
}
async function getUser(id: number) {
return await cache.remember(`user.${id}`, 3600, async () => {
return await db.users.findById(id);
});
}
async function updateUser(id: number, data: any) {
const user = await db.users.update(id, data);
await cache.set(`user.${id}`, user, 3600);
await cache.tags([`user.${id}`]).invalidate(); // Also invalidate tags
return user;
}
// Cache with tags
const tagged = cache.tags([`user.${id}`, "users"]);
await tagged.set(`user.${id}`, userData);
// Invalidate on update
async function updateUser(id: number, data: any) {
await db.users.update(id, data);
await cache.tags([`user.${id}`]).invalidate();
}

similar() looks up entries by vector similarity instead of exact key match. Useful for semantic caching, RAG, and “find similar” features. A few rules of thumb:

// ✅ GOOD: production similarity → pg + pgvector
options: {
pg: {
client: pool,
vector: { dimensions: 1536, index: "hnsw" },
},
}
// ✅ GOOD: dev / small datasets → memory family (brute-force)
options: { memory: { ttl: "1h" } }
// ❌ BAD: brute-force memory in production with 100k entries
// Each query is O(N). Switch to pg + pgvector when the dataset grows past
// a few thousand entries.
// ❌ BAD: mixing dimensions
// Vectors are not portable across embedders. Don't switch embedders
// without re-embedding the whole index.

Re-embed when you change embedders. There’s no way to “convert” between vector spaces — different models live in different geometries, and cosineSimilarity will return scores that look reasonable but aren’t comparable.

  • High memory usage? Set maxSize or use LRU driver
  • Low hit rate? Check TTLs are appropriate for your data
  • Stampedes? Use remember() instead of manual get/set
  • Race conditions? Use atomic operations — increment() / decrement() for counters, set(k, v, { onConflict: "create" }) for distributed locks, update() / merge() for structured values
  • Cache not shared? Use Redis driver for distributed systems
  • similar() slow on memory driver? That’s brute force — switch to pg + pgvector for production-scale similarity
  • CacheUnsupportedError from similar()? The current driver doesn’t index vectors. Check the capability matrix.

Key patterns:

  • Use remember() to prevent cache stampedes
  • Use tags for related data invalidation
  • Choose TTLs based on data freshness requirements
  • Use atomic operations for counters
  • Set memory limits on memory drivers
  • Avoid caching sensitive data without encryption
  • Use Redis for distributed systems

Pass the TTL on each atomic increment rather than relying on a separate create-only seed:

await cache.increment("login.failures", 1, { ttl: "15m" });

The TTL applies when a key is missing or expired, and also repairs live counters that have no expiry. Live keys with an expiry keep their current deadline. Redis increments and applies expiry together with one Lua script, and Postgres uses one upsert. A key that expires immediately after a seed is therefore recreated with an expiry, even when the cache driver has no default TTL. Tagged and scoped calls accept the same option; namespaces use their configured TTL by default. Use a shared Redis or Postgres driver for counters shared across servers.