npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tricache

v0.6.0

Published

Three-tier Node.js cache: L1 smart RAM (adaptive LFU/LRU + Count-Min Sketch) → L1.5 NVMe disk spill → L2 Redis/Valkey. Includes AES-256-GCM at-rest encryption, WASM Bloom filter, Stale-While-Revalidate, and thundering-herd prevention.

Downloads

1,046

Readme

TriCache

CI npm version npm downloads License: MIT Node.js ≥ 22 TypeScript L1 get Thundering herd

tricache is an extremely fast three-tier Node.js cache library. It serves warm reads at 2.81 million operations per second from a single thread — over 100× faster than a localhost Redis round-trip and below any network latency floor. When L1 fills, evicted entries spill to a local NVMe disk tier rather than being dropped, keeping hit rates high without unbounded RAM growth. Cache misses that reach L2 (Redis or Valkey) are automatically coalesced: no matter how many concurrent callers miss the same key, fetchFn fires exactly once. See the performance section for full numbers. Stale-While-Revalidate, AES-256-GCM at-rest encryption, pub/sub fleet-wide invalidation, OOM guard, cold-start snapshots, and Prometheus metrics are also supported through optional configuration — with zero required fields to get started.


✨ Features

| Feature | Detail | |---|---| | Adaptive eviction | LFU × LRU × priority score + Count-Min Sketch cross-eviction frequency; reservoir-sampled O(1) hot path; category limits prevent any prefix monopolising RAM | | Count-Min Sketch | 4 × 512 Uint16Array (4 KB) tracks historical access frequency across eviction boundaries — same-priority burst keys cannot displace long-resident entries; 78 % survival rate in benchmark flood tests | | WASM Bloom filter | 562-byte binary inlined as Base64 — O(k=7) guaranteed-miss detection, no filesystem access, pure-JS fallback | | msgpackr serialization | All entries packed with msgpackr — uniform binary format, no JSON at any payload size | | Stale-While-Revalidate | Serve stale instantly, revalidate in background — zero added latency on cache hit | | Stale-if-error | Extend a stale entry's TTL when SWR revalidation fails — no errors served during upstream outages | | Thundering-herd prevention | Inflight Promise registry — only one fetchFn call per key regardless of concurrency | | Pub/sub invalidation backplane | Redis pub/sub channel propagates deletes across all instances in real time | | Tag-based invalidation | Tag entries on write; invalidateTag('catalog') evicts all matching entries from L1, disk, and Redis atomically | | Batch read | mget() collects L1 hits, calls fetchFn only for misses, preserves ordering | | Batch write | mset() / mdel() write or delete many keys in a single Promise.all call | | TTL jitter | ttlJitterFactor spreads expirations across a configurable ± window — prevents thundering-cliff mass-expiry | | OpenTelemetry spans | Structural ICacheTracer / ICacheSpan interfaces — pass any OTEL-compatible tracer; no peer dep required | | L2 circuit breaker | Suspends Redis after N consecutive failures; auto-probes after cooldown; state visible in metrics() | | warmFromL2(pattern) | Scan Redis and pre-populate L1 at startup; returns count loaded; no-op when Redis unavailable | | OOM guard | Polls heapUsed/heapTotal on a timer; emergency-evicts coldest L1 entries before the process crashes | | Cold-start snapshot | L1 serialised to disk on SIGTERM/SIGINT, reloaded on next startup — warm cache, cold process | | AES-256-GCM encryption | L2 (Redis) values, disk spill files, and snapshots encrypted at rest; zero-downtime key rotation via previousEncryptionKey | | Prometheus metrics | cache.metrics() + CacheService.toPrometheusText() — drop into any /metrics endpoint | | Distributed counter | cache.increment() backed by Redis INCR for distributed rate limiting; in-process fallback when Redis is disabled | | Pluggable logger | Bring your own pino, winston, etc. | | L2 read-only mode | l2WriteMode: 'read-only' reads from Redis but skips all writes — canary deploys, read replicas | | Eviction callback | onEviction(key, reason) fires on every L1 eviction with a typed reason string | | Negative caching (notFoundTtl) | Cache null/undefined fetchFn results for a configurable TTL — prevents hammering upstream on repeated misses | | setIfAbsent() | Atomic "set if not cached" — L1 has() check → Redis SET NX EX → L1 set on success; returns true if written, false if already present | | Refresh-ahead | Proactively recompute an entry in the background when remaining TTL falls below a configured fraction — zero-latency freshness | | XFetch probabilistic early expiry | Probabilistic background recompute keyed to last fetch duration and xfetchBeta — optimal protection against expiry spikes under load | | Adaptive TTL | Tracks per-key fetch latency in a rolling ring buffer; once ≥ 5 samples are collected, automatically sets TTL = p95LatencyMs × multiplier. Expensive keys get cached longer; cheap keys stay close to their base TTL — no manual TTL tuning required | | hotKeys(n) | Returns top N keys by Count-Min Sketch access frequency with size — no full Map scan | | dependsOn cascade invalidation | Tag entries with parent keys; deleting a parent automatically evicts all declared dependents from L1 | | onHit / onMiss callbacks | Per-operation hit/miss hooks with tier info ('l1' | 'disk' | 'l2') — no wait for the metrics interval | | frozen mode | Dev-time mutation guard — Object.freeze() applied recursively to every L1 hit so accidental mutations throw immediately | | tags in get() opts | Attach tags at read time; when fetchFn populates the entry on a miss the tags are registered automatically |


📦 Install

npm install tricache
# or
pnpm add tricache

🚀 Quick start

import { CacheService, CachePriority } from 'tricache';

// Get (or create) the process-level singleton
const cache = CacheService.create({
  redisHost: 'my-redis.example.com',   // omit or set NODE_ENV!=production to disable L2
});

// Get-or-fetch with a 5-minute TTL
const user = await cache.get(
  `user:${userId}`,
  () => db.users.findById(userId),
  300,
);

// Explicit set
await cache.set(`user:${userId}`, user, 300);

// Delete one key
await cache.delete(`user:${userId}`);

// Delete by glob pattern
await cache.delete(`user:${userId}:*`);

// Stale-While-Revalidate: serve stale for up to 30 s while refreshing in background
const dashboard = await cache.get(
  `dashboard:${orgId}`,
  () => analytics.buildDashboard(orgId),
  300,
  { swr: 30 },
);

// Distributed rate-limiting counter
const hits = await cache.increment(`ratelimit:${ip}`, 60 /* TTL seconds */);

// Check if a key is cached (fast, no fetch)
const isCached = cache.has(`user:${userId}`);

// Batch read
const [userA, userB] = await cache.mget(
  [`user:${userIdA}`, `user:${userIdB}`],
  (missKeys) => db.users.findByIds(missKeys).then(rowsToMap),
  300,
);

// Batch write
await cache.mset({
  [`user:${userIdA}`]: { value: userA, ttl: 300 },
  [`user:${userIdB}`]: { value: userB, ttl: 300 },
});

// Batch delete
await cache.mdel([`user:${userIdA}`, `user:${userIdB}`]);

// Warm L1 from Redis at startup
const loaded = await cache.warmFromL2('user:*');
console.log(`Pre-warmed ${loaded} user entries`);

// Or auto-warm at construction + gate traffic with ready()
const cache2 = CacheService.create({ warmKeys: 'user:*' });
await cache2.ready(); // resolves once warm-up completes — ideal for k8s readiness probes

// Atomic set-if-absent — returns true if written, false if key already cached
const written = await cache.setIfAbsent(`session:${id}`, sessionData, 3600);

// Dependency cascade: deleting 'org:42' automatically evicts 'org:42:config'
await cache.set('org:42:config', config, 300, undefined, { dependsOn: ['org:42'] });
await cache.delete('org:42'); // also evicts org:42:config

// Top 10 hottest keys by Count-Min Sketch frequency
const hot = cache.hotKeys(10);
console.log(hot); // [{ key: 'user:1', hits: 842, sizeBytes: 512 }, ...]

// Tag entries for group invalidation
await cache.set(`product:${id}`, product, 300, undefined, { tags: ['catalog'] });
await cache.invalidateTag('catalog'); // evict all catalog entries

// Health check with tier latencies
const { l1, disk, l2 } = await cache.ping();

// Prometheus metrics
const snap = cache.metrics();
console.log(CacheService.toPrometheusText(snap));

⚙️ Configuration

All options are optional — sensible defaults apply.

CacheService.create({
  // ── Namespace ─────────────────────────────────────────────────────────
  // Isolates keys, disk dir, snapshot file, and Redis backplane channel.
  // Two instances with different namespaces are fully independent.
  namespace: 'my-app',

  // ── Logger ────────────────────────────────────────────────────────────
  logger: pinoLogger,               // default: console warn/error only

  // ── L1 (in-memory) ───────────────────────────────────────────────────
  l1MaxBytes:   200 * 1024 * 1024,  // 200 MB total RAM cap (default)
  l1MaxEntries: 2_000,              // max entries in L1 (default)
  l1EvictionWatermark: 0.9,         // proactive eviction fires at 90 % of l1MaxEntries / l1MaxBytes (default)
                                    // lower to 0.8 to reduce GC pressure on heap-bound workloads
  categoryLimits: {
    // per-prefix limits — keys are matched by startsWith()
    'user:':      { maxEntries: 500,  maxSizeBytes: 50  * 1024 * 1024 },
    'analytics:': { maxEntries: 100,  maxSizeBytes: 20  * 1024 * 1024 },
    'default':    { maxEntries: 1000, maxSizeBytes: 100 * 1024 * 1024 },
  },

  // ── L1.5 (disk spill) ────────────────────────────────────────────────
  diskCacheDir:      '/tmp/my-app-cache',  // default: os.tmpdir()/tricache-disk
  diskMaxBytes:      500 * 1024 * 1024,   // 500 MB (default)
  diskEntryMaxBytes: 10  * 1024 * 1024,   // 10 MB per entry (default)

  // ── L2 (Redis / Valkey) ──────────────────────────────────────────────
  redisHost:    'my-redis.example.com',   // or REDIS_HOST env var
  redisPort:    6379,
  redisTls:     true,                     // default: true when NODE_ENV=production
  disableRedis: false,                    // default: true when NODE_ENV!=production

  // ── Invalidation backplane ───────────────────────────────────────────
  // Redis pub/sub channel that propagates deletes to all instances.
  // Enabled by default when Redis is active.
  invalidationBackplane: true,

  // ── OOM guard ────────────────────────────────────────────────────────
  oomProtection:      true,   // enabled by default
  oomHeapThreshold:   0.85,   // evict when heapUsed/heapTotal > 85 %
  oomCheckIntervalMs: 10_000, // poll every 10 s
  oomEvictPercent:    0.20,   // evict coldest 20 % of L1 per trigger

  // ── Encryption ───────────────────────────────────────────────────────
  // base64-encoded 32-byte key; or set CACHE_ENCRYPTION_KEY env var.
  // node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
  encryptionKey: process.env.CACHE_ENCRYPTION_KEY,

  // Zero-downtime key rotation — remove after all old entries have expired
  previousEncryptionKey:  process.env.PREV_ENCRYPTION_KEY,
  previousEncryptionMode: 'aes-256-gcm', // defaults to current encryptionMode

  // ── L2 write mode ────────────────────────────────────────────────────
  // 'read-write' (default) — reads and writes to Redis
  // 'read-only'            — reads from Redis, skips all writes (canary / replica)
  l2WriteMode: 'read-write',

  // ── Stale-if-error ───────────────────────────────────────────────────
  // Extra seconds to extend a stale L1 entry's expiry when a SWR fetchFn fails.
  // Prevents serving errors while the upstream is temporarily down.
  staleIfError: 300, // keep stale for 5 more minutes on revalidation error

  // ── Eviction callback ────────────────────────────────────────────────
  // Called synchronously whenever L1 evicts a key.
  // reason: 'capacity' | 'category' | 'rebalance' | 'oom' | 'ttl' | 'manual'
  onEviction: (key, reason) => metrics.increment(`cache.eviction.${reason}`),

  // ── TTL jitter ────────────────────────────────────────────────────────
  // Multiply each TTL by a random factor in [1-j, 1+j] to spread expiry.
  // Prevents mass-expiry stampedes ("thundering cliff").
  // Range [0, 1]; default 0 (no jitter).
  ttlJitterFactor: 0.15,  // ± 15 % spread

  // ── Adaptive TTL ──────────────────────────────────────────────────────
  // When true, tricache tracks per-key fetch latency in a rolling ring
  // buffer and derives an optimal TTL from the p95 fetch duration:
  //   adaptedTtl = clamp(p95LatencyMs × multiplier, min, max)
  // The caller-supplied ttlSeconds is used until ≥ 5 samples are collected,
  // then the library takes over TTL management autonomously.
  adaptiveTtl:            true,
  adaptiveTtlMin:         10,      // floor: never assign TTL below 10 s (default)
  adaptiveTtlMax:         86400,   // ceiling: never exceed 24 h (default)
  adaptiveTtlMultiplier:  20,      // p95Ms × 20 = TTL in seconds (default)

  // ── OpenTelemetry tracer ──────────────────────────────────────────────
  // Pass any @opentelemetry/api-compatible tracer. No peer dependency.
  // Spans: 'tricache.get' | 'tricache.set' | 'tricache.delete'
  // Attributes: cache.key_prefix, cache.hit ('l1'|'disk'|'l2'|'miss')
  tracer: trace.getTracer('my-app'),

  // ── L2 circuit breaker ────────────────────────────────────────────────
  // Opens after N consecutive Redis errors; probes after cooldown ms.
  // State visible in cache.metrics().l2CircuitBreaker.state
  l2CircuitBreakerThreshold:  5,      // default
  l2CircuitBreakerCooldownMs: 30_000, // default

  // ── Negative caching ──────────────────────────────────────────────────
  // Cache null/undefined fetchFn results for this many seconds globally.
  // Prevents repeated upstream calls for keys that genuinely don't exist.
  // Can be overridden per-call via opts.notFoundTtl in cache.get().
  notFoundTtl: 30, // seconds; 0 = disabled (default)

  // ── Startup warm-up ───────────────────────────────────────────────────
  // Auto-call warmFromL2(pattern) at construction time.
  // cache.ready() resolves once warm-up finishes — use as a k8s readiness gate.
  // No-op when Redis is disabled or unreachable.
  warmKeys: 'user:*',

  // ── Prometheus instance label ─────────────────────────────────────────
  // Adds an `instance` label to every metric in toPrometheusText().
  instanceName: 'api-us-east-1',

  // ── Cold-start snapshot ──────────────────────────────────────────────
  snapshotPath:              '/tmp/my-app-cache-snapshot.msgpack',
  snapshotMaxAgeMs:          2 * 60 * 60 * 1000,  // 2 hours (default)
  forbiddenSnapshotPrefixes: ['auth:', 'session:', 'mfa:', 'rate_limit:'],

  // ── Metrics callback ─────────────────────────────────────────────────
  metricsIntervalMs: 60_000,                       // emit every 60 s (default)
  onMetrics: (m) => myMonitoring.record(m),        // optional push callback

  // ── Per-operation hooks ───────────────────────────────────────────────
  // onHit fires on every L1, disk, or L2 hit with the caller-facing key (no prefix)
  // and the tier that served it. Lower latency than waiting for onMetrics.
  onHit:  (key, tier) => cloudwatch.putMetricData({ key, tier }),

  // onMiss fires when all three tiers are exhausted — before fetchFn is called.
  onMiss: (key) => cloudwatch.putMetricData({ key }),

  // ── Development mutation guard ────────────────────────────────────────
  // When true, every L1 hit value is deep-frozen before being returned.
  // Mutation attempts throw TypeError immediately in development.
  // Do NOT enable in production — deep-freezing large objects has measurable overhead.
  frozen: process.env.NODE_ENV !== 'production',
});

Environment variables

| Variable | Purpose | |---|---| | REDIS_HOST | Redis/Valkey hostname (used when redisHost option is not set) | | CACHE_ENCRYPTION_KEY | Base64-encoded 32-byte AES-256-GCM key | | NODE_ENV | When !== 'production', L2 Redis and TLS are disabled by default |


📖 API reference

CacheService.create(options?)CacheService

Returns the process-level singleton. Options are only applied on the first call per namespace — subsequent calls return the existing instance.

CacheService.createAsync(optionsOrPromise)Promise<CacheService>

Async factory that resolves a Promise<CacheOptions> before constructing the singleton. Useful when config is fetched from a secret store at startup.

const cache = await CacheService.createAsync(fetchSecretsFromVault());

CacheService.reset(options?)CacheService

Destroys the existing singleton and creates a fresh one. Useful in tests.

cache.get<T>(key, fetchFn, ttlSeconds?, opts?)Promise<T>

Get from cache or call fetchFn on a miss. The inflight map ensures fetchFn fires at most once per key regardless of concurrency.

Reference semantics: on an L1 hit, the returned value is the live JS object stored in the entry — not a deep copy. Mutating it will corrupt the cached entry. Deep-clone at the call site if you need an independent copy.

| Parameter | Type | Default | Description | |---|---|---|---| | key | string | — | Cache key | | fetchFn | () => Promise<T> | — | Called on a miss; result is cached | | ttlSeconds | number | 300 | Hard TTL in seconds | | opts.swr | number | 0 | Stale-While-Revalidate grace seconds | | opts.priority | CachePriority | auto-inferred | Eviction priority override | | opts.refreshAhead | number | — | Fraction (0, 1] of TTL — triggers background recompute when remaining ≤ ttl × (1 - refreshAhead) | | opts.xfetchBeta | number | — | XFetch β ≥ 0 — scales probabilistic early recompute by last fetch duration; higher = recompute earlier | | opts.notFoundTtl | number | — | Per-call TTL in seconds for null/undefined results (overrides global notFoundTtl) | | opts.tags | string[] | — | Tags to register when fetchFn populates the entry on a miss; no-op on L1/L2 hits where tags are already registered |

cache.set<T>(key, data, ttlSeconds?, priority?, opts?)Promise<void>

Writes to L1 and (in production) L2. Publishes an invalidation to the backplane.

| Parameter | Type | Default | Description | |---|---|---|---| | opts.tags | string[] | [] | Associate tags with this entry for group invalidation | | opts.dependsOn | string[] | [] | Parent keys — when any parent is deleted, this entry is automatically evicted from L1 |

await cache.set('product:1', data, 60, undefined, { tags: ['catalog', 'featured'] });

// Cascade invalidation: evicting 'org:42' also evicts 'org:42:members'
await cache.set('org:42:members', members, 300, undefined, { dependsOn: ['org:42'] });
await cache.delete('org:42'); // org:42:members is evicted too

cache.mget<T>(keys, fetchFn, ttl?, priority?)Promise<(T | undefined)[]>

Batch read. Returns L1-cached values for hot keys; calls fetchFn only with the keys that missed. Preserves input ordering.

ttl accepts a plain number (uniform TTL) or a function (key: string) => number (per-key TTL). The function is only called for miss keys — L1 hits are unaffected.

// Uniform TTL
const [userA, userB] = await cache.mget(
  ['user:1', 'user:2'],
  (missKeys) => db.users.findByIds(missKeys).then(rowsToMap),
  300,
);

// Per-key TTL — heterogeneous data in one batch call
const results = await cache.mget(
  ['user:1', 'config:global', 'feature:flags'],
  fetchFn,
  (key) => key.startsWith('config:') ? 3600 : 300,
);

cache.mset<T>(entries)Promise<void>

Write multiple entries in a single call. Each entry accepts value, ttl, priority, and tags.

await cache.mset({
  'user:1': { value: alice, ttl: 300, priority: CachePriority.HIGH, tags: ['users'] },
  'user:2': { value: bob,   ttl: 300 },
});

cache.mdel(keys)Promise<void>

Delete multiple keys in a single call. No-op for keys that do not exist.

await cache.mdel(['user:1', 'user:2', 'user:3']);

cache.warmFromL2(pattern)Promise<number>

Scan Redis for keys matching a glob pattern (e.g. 'user:*') and load their values into L1 with a 10-minute TTL. Returns the number of keys loaded. Returns 0 immediately when Redis is disabled or unreachable — safe to call unconditionally at startup.

// In your application startup
const loaded = await cache.warmFromL2('user:*');
console.log(`Pre-warmed ${loaded} user entries from Redis`);

cache.ready()Promise<void>

Returns a Promise that resolves once the cache is fully initialised. Without warmKeys, resolves immediately. With warmKeys, resolves once the automatic warmFromL2 call completes.

Designed for k8s readiness probes — await before accepting traffic, then never call again:

const cache = CacheService.create({ warmKeys: 'user:*' });

// k8s readiness probe endpoint
app.get('/ready', async (_req, res) => {
  await cache.ready();
  res.sendStatus(200);
});

cache.has(key)boolean

Return true if the key exists in L1 and has not expired. Bloom-filter fast-path — no fetch, no disk or Redis round-trip.

cache.ttl(key)number | null

Return the remaining TTL in seconds for a key currently held in L1. Returns null if the key is absent or expired. Does not fetch or consume the value.

const remaining = cache.ttl('user:123'); // e.g. 247 (seconds left)
if (remaining !== null && remaining < 30) await cache.touch('user:123', 300);

cache.touch(key, newTtlSeconds)Promise<boolean>

Extend the TTL of a key in L1 (and fire-and-forget EXPIRE in Redis) without reading or re-fetching its value. Returns false if the key is absent or already expired.

cache.getIfFresh<T>(key)T | null

Return the L1 value only if it is fresh (not yet in the SWR grace window). Returns null when absent, expired, or stale — without triggering a revalidation.

const fresh = cache.getIfFresh<User>('user:123');
if (fresh !== null) return fresh; // serve from L1, no network hop

cache.setIfAbsent<T>(key, value, ttlSeconds?)Promise<boolean>

Atomically write value only if key is not already cached. Checks L1 first, then attempts a Redis SET NX EX. Returns true if the value was written, false if a live entry already existed.

Useful for distributed lock-style writes, session initialisation, or any pattern where you must not overwrite an already-cached value.

const written = await cache.setIfAbsent(`session:${id}`, sessionData, 3600);
if (!written) {
  // session already exists — do not overwrite
}

cache.hotKeys(n?)Array<{ key: string; hits: number; sizeBytes: number }>

Returns the top n live L1 keys ranked by Count-Min Sketch access frequency. Namespace prefix is stripped from each key. Expired entries are excluded. Default n = 10.

const hot = cache.hotKeys(5);
// [
//   { key: 'user:1',    hits: 1024, sizeBytes: 512 },
//   { key: 'product:7', hits:  893, sizeBytes: 256 },
//   ...
// ]

cache.invalidateTag(tag)Promise<void>

Evict all entries associated with a tag from L1, disk, and Redis.

await cache.set('product:1', data, 60, undefined, { tags: ['catalog'] });
await cache.set('product:2', data, 60, undefined, { tags: ['catalog'] });
await cache.invalidateTag('catalog'); // evicts both entries

cache.ping()Promise<CachePingResult>

Measure L1 / disk / Redis latency in milliseconds. Returns { l1, disk, l2 }l2 is null when Redis is disabled. Suitable for health-check endpoints.

app.get('/health', async (_req, res) => {
  const { l1, disk, l2 } = await cache.ping();
  res.json({ status: 'ok', latencyMs: { l1, disk, l2 } });
});

cache.drainToL2()Promise<number>

Pipeline all live L1 entries to Redis in a single round-trip. Returns the number of keys written. Useful for warming a new Redis node or zero-downtime failover.

cache.delete(key)Promise<void>

Deletes one exact key or a glob pattern (user:abc:*). Propagates to disk, Redis, and all backplane peers.

cache.clear(prefix?)Promise<void>

Flush all entries, or only those whose key starts with prefix. Propagates to disk and Redis.

await cache.clear();           // flush everything
await cache.clear('session:'); // flush only session keys

cache.rebalance()void

Evict L1 entries that now violate the current category or global capacity limits. Useful when categoryLimits are tightened after startup — normally, existing entries are not re-evaluated until they expire naturally.

// Tighten analytics limit at runtime, then immediately enforce it
cache.options.categoryLimits['analytics:'].maxEntries = 50;
cache.rebalance();

cache.increment(key, ttlSeconds?)Promise<number>

Redis INCR — atomically increments a counter, setting TTL on first write. When Redis is disabled, maintains an in-process counter with the same TTL semantics so rate-limiting works in dev/test.

cache.metrics()CacheMetrics

Returns a full metrics snapshot including hit rates, bloom filter stats, backplane counters, OOM eviction history, and tier sizes.

CacheService.toPrometheusText(metrics, prefix?, instanceName?)string

Converts a CacheMetrics snapshot to Prometheus text exposition format. Pass instanceName to add an instance label alongside namespace.

app.get('/metrics', (_req, res) => {
  res.type('text/plain').send(
    CacheService.toPrometheusText(cache.metrics(), 'tricache', 'api-us-east-1'),
  );
});

cache.stats(){ l1, disk }

Lightweight L1 and disk stats without the full metrics breakdown.

cache.writeSnapshot(altPath?) / cache.loadSnapshot()

Manual snapshot control. Called automatically on SIGTERM/SIGINT — only needed when you manage shutdown yourself. writeSnapshot() accepts an optional path to write to an alternate location without touching the configured default snapshot file.

// Graceful-shutdown hook — write to a dated backup path
process.on('SIGTERM', async () => {
  await cache.writeSnapshot(`/backups/cache-${Date.now()}.snap`);
  process.exit(0);
});

cache.keys()Generator<string>

Lazily yields the key for every live (non-expired) L1 entry. Namespace prefix is stripped automatically. Uses a dedicated generator that skips intermediate tuple allocation.

for (const key of cache.keys()) console.log(key);

cache.values<T>()Generator<T>

Lazily yields the cached value for every live L1 entry. Returns the live deserialized object (same reference semantics as get()). Uses yield* delegation — no intermediate generator frame.

for (const session of cache.values<Session>()) evict(session);

cache.entries<T>()Generator<[string, T]>

Lazily yields [key, value] pairs for every live L1 entry. Key has namespace prefix stripped.

for (const [key, user] of cache.entries<User>()) sync(key, user);

JIT note: all three generators iterate SmartMemoryCache.cache (a single Map). V8 maintains per-call-site type feedback; having three generator functions share the same Map means no single one gets the full monomorphic specialization budget. In practice the throughput impact is ≤5 % relative to each running in isolation. See BENCHMARKS.md for numbers.

cache.destroy()Promise<void>

Closes the Redis connection, unsubscribes the backplane, and stops all background timers.


🎯 Priority levels

import { CachePriority } from 'tricache';

CachePriority.LOW      // 1 — analytics, reports — evicted first
CachePriority.NORMAL   // 2 — general application data (default)
CachePriority.HIGH     // 3 — user profiles, config — evicted last
CachePriority.CRITICAL // 4 — never evicted while valid (auth tokens, sessions)

Priority is auto-inferred from the key when not specified:

| Key contains | Inferred priority | |---|---| | auth: or session: | CRITICAL | | user:, org:, or profile: | HIGH | | analytics:, report:, or stats: | LOW | | anything else | NORMAL |


🧠 Eviction algorithm

L1 eviction uses reservoir sampling — an O(n) single pass samples 16 candidates, then sorts only those 16 (O(1)). Each candidate is scored:

score = priority × 1000 + min(hits, 100) × 10 + ttlRemaining/60s − age/60s
  • Higher score = kept longer
  • CRITICAL entries are excluded from sampling while valid
  • When a category limit is breached, entries from that category receive a score penalty

🪵 Pluggable logger

Bring your own structured logger — tricache doesn't care if it's pino, winston, or console.

import pino from 'pino';
const logger = pino();

CacheService.create({
  logger: {
    debug: (msg, meta) => logger.debug(meta ?? {}, msg),
    info:  (msg, meta) => logger.info(meta  ?? {}, msg),
    warn:  (msg, meta) => logger.warn(meta  ?? {}, msg),
    error: (msg, meta, err) => logger.error({ ...(meta ?? {}), err }, msg),
  },
});

🔐 Encryption

AES-256-GCM for L2 (Redis) values, disk spill files, and cold-start snapshots. Three modes are available via encryptionMode:

| Mode | Key length | Notes | |---|---|---| | aes-256-gcm | 32 bytes | Default. Authenticated encryption (AEAD). | | aes-128-gcm | 16 bytes | ~15% faster than AES-256. Same AEAD guarantees. | | aes-128-ctr | 16 bytes | Fastest cipher mode. AES-NI keystream, no auth tag. Use when integrity is guaranteed elsewhere (TLS, HMAC). | | xor | any (≥ 16 bytes recommended) | NOT cryptographic. XOR obfuscation only. Dev/non-sensitive data. |

Key generation:

# AES-256 (32 bytes)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

# AES-128 / AES-128-CTR (16 bytes)
node -e "console.log(require('crypto').randomBytes(16).toString('base64'))"

# XOR — any length, minimum 16 bytes recommended
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
// AES-256-GCM (default)
CacheService.create({ encryptionKey: '<base64-32-bytes>' });

// AES-128-GCM
CacheService.create({ encryptionKey: '<base64-16-bytes>', encryptionMode: 'aes-128-gcm' });

// AES-128-CTR (fastest cipher, no auth tag)
CacheService.create({ encryptionKey: '<base64-16-bytes>', encryptionMode: 'aes-128-ctr' });

// XOR obfuscation (NOT cryptographic — dev/non-sensitive only)
CacheService.create({ encryptionKey: '<base64-key>', encryptionMode: 'xor' });

// or use the env var: CACHE_ENCRYPTION_KEY=<base64-key>

| Mode | Redis format | Disk / snapshot format | |---|---|---| | aes-256-gcm | enc:v1:<base64(IV[12]\|Tag[16]\|CT)> | TRIC1ENC\|IV[12]\|Tag[16]\|CT[N] | | aes-128-gcm | a128:v1:<base64(IV[12]\|Tag[16]\|CT)> | TRIC1128\|IV[12]\|Tag[16]\|CT[N] | | aes-128-ctr | ctr:v1:<base64(IV[16]\|CT)> | TRIC1CTR\|IV[16]\|CT[N] | | xor | xor:v1:<base64(key⊕data)> | TRIC1XOR\|key⊕data[N] |

Existing plaintext values are read transparently during key rotation.

Zero-downtime key rotation

Set previousEncryptionKey to your old key while rolling out a new one. The cache tries the current key first; if decryption fails it transparently retries with the previous key. Remove previousEncryptionKey once all old entries have expired.

CacheService.create({
  encryptionKey:         process.env.NEW_ENCRYPTION_KEY, // new AES-256 key
  previousEncryptionKey: process.env.OLD_ENCRYPTION_KEY, // fallback for old entries
  // previousEncryptionMode defaults to current encryptionMode
});

⚡ WASM Bloom filter

A 100,000-bit filter with k=7 hash probes:

  • At the default l1MaxEntries: 2,000 — false-positive rate ≈ 0.01%
  • At rated capacity (~18,000 entries) — false-positive rate ≈ 1%
  • The filter rebuilds automatically when stale bits from deleted/expired entries accumulate

Mechanics:

  • mightContain(key) === falseguaranteed miss — the Map lookup is skipped entirely
  • mightContain(key) === true → probable hit — the Map is checked to confirm

The 562-byte WASM binary is inlined as Base64 — zero filesystem access at runtime. Falls back to a pure-JS implementation if WebAssembly is unavailable.


📊 Performance

Measured on a single Node.js thread (no await on synchronous paths):

L1 SmartMemoryCache

| Operation | Throughput | Latency | Notes | |---|---|---|---| | get — hot hit (8K entries) | 2.81 M/s | 356 ns | bloom → Map lookup → return cached value | | get — cold miss | 7.14 M/s | 140 ns | bloom gates → early return | | set — tiny payload | 899 K/s | 1.11 µs | pack() + Map.set + bloom.add | | set — small payload (≈ 512 B) | 554 K/s | 1.81 µs | pack() same unified path, larger payload | | set — large payload (≥ 512 B) | 205.3 K/s | 4.87 µs | pack() larger payload | | set — CRITICAL priority | 730.1 K/s | 1.37 µs | same set path; skipped in eviction sort | | delete — exact key | 5.36 M/s | 186 ns | Map.delete | | deletePattern — glob wildcard | 7.2 K/s | 138 µs | O(n) Map scan | | Count-Min Sketch estimate | 3.37 M/s | 297 ns | 4 row lookups — called on every get() hit and set() |

Iterator interface (L1 live entries, 500 entries)

| Method | Throughput | Latency | Notes | |---|---|---|---| | cache.keys() | 26.6 K/s | 37.53 µs | no [key,entry] tuple allocation | | cache.values() | 35.5 K/s | 28.19 µs | yield* delegation | | cache.entries() | 24.0 K/s | 41.73 µs | [strippedKey, value] pairs | | raw Map iteration (baseline) | 277.2 K/s | 3.61 µs | no expiry check, no generator overhead |

CacheService (end-to-end)

| Operation | Throughput | Latency | Notes | |---|---|---|---| | get — L1 warm hit | 2.03 M/s | 491 ns | inflight check → l1.get → return cached value | | get — SWR stale serve | 1.78 M/s | 562 ns | serves stale; revalidates async | | get — miss + fetchFn | 13.7 K/s | 73 µs | Promise microtask + l1.set | | set | 28.7 K/s | 34.86 µs | l1.set + disk.save (fire-and-forget) | | delete — exact key | 7.3 K/s | 137.82 µs | l1.delete + disk.delete + backplane | | delete — glob * | 687 K/s | 1.46 µs | l1.deletePattern O(n) + disk glob |

Encryption (IV pool, pre-allocated output buffers)

| Mode | Payload | Encrypt | Decrypt | |---|---|---|---| | AES-256-GCM | 64 B | 140.4 K/s / 7.12 µs | 155.5 K/s / 6.43 µs | | AES-256-GCM | 512 B | 103.1 K/s / 9.70 µs | 142.9 K/s / 6.99 µs | | AES-256-GCM | 4 KB | 58.4 K/s / 17.12 µs | 48.0 K/s / 20.84 µs | | AES-128-GCM | 64 B | 148.8 K/s / 6.72 µs | 173.0 K/s / 5.78 µs | | AES-128-GCM | 512 B | 135.7 K/s / 7.37 µs | 158.8 K/s / 6.30 µs | | AES-128-GCM | 4 KB | 70.2 K/s / 14.24 µs | 53.2 K/s / 18.79 µs | | AES-128-CTR | 64 B | 187.9 K/s / 5.32 µs | 196.9 K/s / 5.08 µs | | AES-128-CTR | 512 B | 183.5 K/s / 5.45 µs | 185.6 K/s / 5.39 µs | | AES-128-CTR | 4 KB | 78.4 K/s / 12.75 µs | 71.8 K/s / 13.93 µs | | XOR (obfuscation only) | 64 B | 2.43 M/s / 412 ns | 2.10 M/s / 476 ns | | XOR (obfuscation only) | 512 B | 665.5 K/s / 1.50 µs | 715.3 K/s / 1.40 µs | | XOR (obfuscation only) | 4 KB | 114.5 K/s / 8.73 µs | 77.6 K/s / 12.89 µs |

AES and XOR string-path numbers shown (Redis L2). Buffer path (disk/snapshot) is 5–20% faster — no base64 overhead.
AES-128-GCM is 5–50% faster than AES-256-GCM depending on payload (gap widens at mid-range sizes on AES-NI hardware).
AES-128-CTR removes the GHASH MAC step: ~50% faster than AES-128-GCM at small payloads; use only when integrity is guaranteed by transport.
XOR numbers are for the buffer path (32-bit word-level XOR, 4 bytes/iteration). XOR dominates at small payloads (no cipher setup) and remains ~2× faster than AES at 4 KB.

See BENCHMARKS.md for the full breakdown: bloom filter cost, serialization by payload size, eviction pressure, concurrency analysis, multi-tenancy isolation, and a realistic 80/15/5 read/miss/write workload.


🤝 Contributing

Bug reports and pull requests are welcome!

  1. Fork the repo and create a feature branch
  2. Run pnpm test — all tests must pass
  3. Run pnpm bench if you touch a hot path and include before/after numbers in your PR
  4. Open your PR against master

New to the codebase? Start with src/cache-service.ts for the public API and src/smart-memory-cache.ts for the L1 engine.


🛡️ Security

Found a vulnerability? Please don't open a public issue. Report it privately via GitHub Security Advisories so it can be patched before disclosure.

For encryption key generation and rotation best practices, see the Encryption section.


📄 License

MIT