TriFrost

TriFrost Docs

Learn and build with confidence, from a first project with workers to global scale on bare metal.

Cache

TriFrost ships with built-in caching, powered by the same storage abstraction as rate limiting.

Whether you're caching DB results, expensive computations, or third-party fetches, the API is fast, composable, and runtime-adaptive.

Works with all TriFrost-supported stores: Memory, Redis, KV, and DurableObject.

πŸ‘‰ See also: Rate Limiting | Context API


⚑ Declaring a Cache

To enable caching, pass a cache instance when initializing your app:

import {App, MemoryCache} from '@trifrost/core';

const app = new App<Env>({
  cache: () => new MemoryCache({
    strategy: 'sliding',
    ttl: 60,
  }),
});

πŸ”‘ Basic Usage

You can use ctx.cache directly to get/set/delete values:

app.get('/expensive', async (ctx) => {
  const data = await ctx.cache.wrap('some-key', async () => {
    const result = await expensiveFn();
    return result;
  }, {ttl: 120}); // cache for 2 minutes

  return ctx.json(data);
});

You can also manually control:

await ctx.cache.set('user:123', user, {ttl: 300});
const cached = await ctx.cache.get('user:123');
await ctx.cache.del('user:123');

🧠 Automatic Method Caching

TriFrost supports caching decorators for class or standalone methods:

import {cache} from '@trifrost/core';

class UserService {
  @cache((ctx) => `user:${ctx.state.userId}`, {ttl: 300})
  async getUser(ctx: Ctx) {
    return fetchUser(ctx.state.userId);
  }
}

Or use as a functional wrapper:

const cachedFetch = cacheFn((ctx) => `user:${ctx.state.userId}`, {ttl: 60})(
  async function fetch(ctx) {
    return getFromDB(ctx.state.userId);
  }
);

🚫 Skipping Cache

Sometimes you want to conditionally skip caching, for example, when a fetch fails or returns an invalid result.

TriFrost provides a built-in way to opt out of caching via ctx.cache.skip(...) or the cacheSkip(...) helper:

β›” Inside .wrap(...)

const data = await ctx.cache.wrap('expensive:key', async () => {
  try {
    const result = await fetchStuff();
    return result;
  } catch (err) {
    ctx.logger.warn('Failed to fetch', err);
    return ctx.cache.skip(null); // ❌ don't cache failures
  }
});

β›” Inside a @cache(...) decorated method

import {cache} from '@trifrost/core';

class MyService {
  @cache(ctx => `user:${ctx.state.userId}`, {ttl: 300})
  async getUser(ctx) {
    try {
      return await fetchUser(ctx.state.userId);
    } catch {
      return ctx.cache.skip(null); // ❌ don't cache error state
    }
  }
}

β›” Or manually using the helper

import {cacheSkip} from '@trifrost/core';

const result = await fetchSomething();
if (!result.valid) return cacheSkip(result); // ❌ skip invalid entries

βœ… Skipped results still return immediately β€” they just won’t be written to cache.


πŸ“¦ TTL Semantics

You can provide ttl globally or per-call:

cache.set('some-key', value, {ttl: 300}); // 5 minutes

If omitted, TriFrost uses the global default passed to the cache constructor.

βœ… TTLs are in seconds (not milliseconds).

Adapter Notes
  • Memory: TTL is enforced via Date.now() and internal GC, precision is sharp.
  • Redis: TTL is natively supported via EX, precision is solid.
  • Durable Object: TTL is respected manually (we manage expiry ourselves within the TriFrostDurableObject class).
  • Cloudflare KV:
    - ⚠️ TTL must be β‰₯ 60 seconds, anything lower will be clamped.
    - ⚠️ Setting ttl: 0 will not disable caching, it may result in undefined behavior.
    - ⚠️ Deletion (del(...)) is respected but list batching may delay visibility.

πŸ’‘ When using KV via Workerd, remember that TTL behavior is eventually consistent and can't be used for precise eviction or real-time logic.


πŸ”„ Cache Invalidation

TriFrost lets you delete cache entries explicitly, either by key or by prefix.

Delete by Key:

await ctx.cache.del('user:123');

Deletes a specific key from the store.

Delete by Prefix:

await ctx.cache.del({prefix: 'user:'});

Deletes all entries whose keys start with user:. This is useful for:

  • Invalidating all sessions for a user
  • Flushing namespace groups (product:*, page:*, etc.)
  • Triggering partial flushes after a write or deploy
Adapter Notes
  • Memory: In-process Map; deletes are immediate
  • Redis: Uses SCAN + DEL; safe for production (efficient with reasonable size)
  • Cloudflare KV: Uses batched .list() and .delete(), slower under high key count
  • Cloudflare DurableObject: Prefix-based deletion is built into the internal TriFrostDurableObject storage logic

Cloudflare KV prefix deletion may be eventually consistent. It's not suitable for instant cache invalidation where strong consistency is required.


πŸ’Ύ Storage Backends

TriFrost cache uses the same Store abstraction as rate limiting. It supports:

Memory
  • In-memory Map
  • Zero config
  • Great for dev or per-instance caching
import {MemoryCache} from '@trifrost/core';

const app = new App<Env>({
  cache: () => new MemoryCache({ttl: 60}),
});
KV (Cloudflare)
  • Uses Cloudflare KV
  • Good for global availability
  • Eventual consistency
  • Slower than DO or Redis for rapid reads/writes

Note: Cloudflare KV is eventually consistent and significantly slower than Durable Objects under high concurrency. It's best for soft-caching or global edge, not precise enforcement.

import {App, KVCache} from '@trifrost/core';

type Env = {
  MY_KV: KVNamespace;
};

const app = new App<Env>({
  cache: ({env}) => new KVCache({
    store: env.MY_KV,
    ttl: 180, // 3 minutes
  }),
});

In your wrangler.toml:

[[kv_namespaces]]
binding = "MY_KV"
id = "xxxxxx..."

Note: CloudFlare KV has a minimum ttl of 1 minute (60 seconds)

Redis
  • Traditional Redis (compatible with libs like ioredis)
  • Great for clustered Node apps or centralized backends
  • Low-latency and strongly consistent
import {App, RedisCache} from '@trifrost/core';
import Redis from 'ioredis';

const app = new App({
  cache: ({env}) => new RedisCache({
    store: new Redis(...),
    ttl: 120, // 2 minutes
  }),
});
Durable Objects (Cloudflare)
  • Uses a single Cloudflare Durable Object
  • Sequential consistency guarantees accuracy
  • Ideal for precise or billing-related cache
  • Requires manual registration and export (see below)

Compared to KV, Durable Objects offer lower latency and stronger consistency. They're ideal for precise control (e.g. billing, abuse prevention) and scale well under concurrency.

To use TriFrost's Durable Object-based cache:

import {App, DurableObjectCache, TriFrostDurableObject} from '@trifrost/core';

type Env = {
  MainDurable: DurableObjectNamespace;
};

// Required for Workerd
export {TriFrostDurableObject};

const app = await new App<Env>({
  cache: ({env}) => new DurableObjectCache({
    store: env.MainDurable,
    ttl: 60,
  }),
})
	...
	.boot();

export default app;

In your wrangler.toml:

[[durable_objects.bindings]]
name = "MainDurable"
class_name = "TriFrostDurableObject"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["TriFrostDurableObject"]

Workerd/Cloudflare requires DO classes to be explicitly exported and bound. If missing, caching won't work.

πŸ‘‰ Learn more in: Workerd Deployment Guide


πŸ§ͺ Why cache Is a Function

When configuring a cache, TriFrost expects cache to be a function that receives the current {env}.

This enables:

  • Lazy initialization (e.g. when not all bindings are ready at startup)
  • Runtime-specific resolution (DO/KV vary across regions or workers)
  • Full access to typed ctx.env inside the adapter
  • Ability to switch the rate limiting setup depending on the environment
cache: ({env}) => {
  if (isDevMode(env)) return new MemoryCache({...});
  return new DurableObjectCache({store: env.MainDurable, ...});
},

This ensures your cache integrates seamlessly with edge runtimes and supports async-compatible storage.


Best Practices

  • βœ… Use .wrap(...) for auto-get/set flow
  • βœ… Prefer .wrap over get/set for idempotency
  • βœ… Use cacheFn(...) or @cache(...) for reusable logic
  • βœ… Don’t forget .del(...) supports prefix invalidation
  • βœ… Durable Objects are best for precise or metered cache on Cloudflare
  • βœ… Use prefix-based keys when designing your cache schema, this gives you fine-grained control over invalidation down the line

Loved the read? Share it with others