Skip to content

@studnicky/cache

Capacity-bounded LRU cache with per-entry and default TTL, O(1) promotion on read.

Install

bash
pnpm add @studnicky/cache

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Create an LruCache instance with a capacity, then use set, get, has, delete, and clear:

ts
import { LruCache } from '../src/index.js';

const cache = LruCache.create<string, number>({ 'capacity': 10 });

// set and get a value
cache.set('score', 99);
console.log('get score:', cache.get('score'));

// has returns true for a stored key
console.log('has score:', cache.has('score'));

// size reflects the number of entries
cache.set('level', 3);
console.log('size after two sets:', cache.size);

// delete removes the entry and returns true
const deleted = cache.delete('score');
console.log('deleted score:', deleted);
console.log('has score after delete:', cache.has('score'));
console.log('get score after delete:', cache.get('score'));

// size decrements after delete
console.log('size after delete:', cache.size);

// clear removes all entries
cache.clear();
console.log('size after clear:', cache.size);
console.log('has level after clear:', cache.has('level'));

LRU eviction

When the cache is at capacity, the least-recently-used entry is evicted on the next set. Reading an entry promotes it to most-recently-used:

ts
import { LruCache } from '../src/index.js';

const cache = LruCache.create<string, string>({ 'capacity': 2 });

// Fill to capacity
cache.set('a', 'alpha');
cache.set('b', 'beta');

// Access 'a' — promotes it to MRU; 'b' becomes LRU
const aBeforeEviction = cache.get('a');
console.log('get a (promotes to MRU):', aBeforeEviction);

// Adding 'c' evicts 'b' (least recently used)
cache.set('c', 'gamma');

const aAfter = cache.get('a');
const cAfter = cache.get('c');
const bAfter = cache.get('b');

console.log('get a after eviction:', aAfter);
console.log('get c after eviction:', cAfter);
console.log('get b after eviction (evicted):', bAfter);
console.log('has b:', cache.has('b'));
console.log('size:', cache.size);

TTL expiry

Pass ttlMs to expire entries automatically. Eviction is lazy: entries are removed on the next get or has after the TTL has elapsed:

ts
import { LruCache } from '../src/index.js';

const cache = LruCache.create<string, string>({ 'capacity': 10, 'ttlMs': 10 });

cache.set('token', 'abc123');

// Before expiry, the entry is present
const beforeExpiry = cache.get('token');
console.log('get token before expiry:', beforeExpiry);
console.log('has token before expiry:', cache.has('token'));

// Wait 50ms — the 10ms TTL will have elapsed
await new Promise<void>((resolve) => { setTimeout(resolve, 50); });

// After expiry, get returns undefined (lazy eviction on access)
const afterExpiry = cache.get('token');
console.log('get token after expiry:', afterExpiry);
console.log('has token after expiry:', cache.has('token'));

Bulk insert

setMany inserts many entries at once. Entries are processed in argument order; the last entry in the array is the most-recently-used after the call. When the batch causes capacity overflow, entries earlier in the argument list are evicted first:

ts
import { LruCache } from '../src/index.js';

const cache = LruCache.create<string, number>({ 'capacity': 3 });

// Insert three entries at once; argument order determines recency.
// After the call: 'a' is LRU, 'c' is MRU.
cache.setMany([['a', 1], ['b', 2], ['c', 3]]);

console.log('get a:', cache.get('a')); // 1
console.log('get b:', cache.get('b')); // 2
console.log('get c:', cache.get('c')); // 3

// When the batch exceeds capacity the oldest-by-arg-order entry is evicted first.
// The cache is at capacity (3). Adding two more entries evicts 'a' then 'b'.
cache.setMany([['d', 4], ['e', 5]]);

console.log('get a after eviction:', cache.get('a')); // undefined — evicted (was LRU)
console.log('get b after eviction:', cache.get('b')); // undefined — evicted next
console.log('get c after eviction:', cache.get('c')); // 3
console.log('get d after eviction:', cache.get('d')); // 4
console.log('get e after eviction:', cache.get('e')); // 5

// A batch TTL applies to every entry in the call.
const ttlCache = LruCache.create<string, string>({ 'capacity': 10 });
ttlCache.setMany([['token', 'abc'], ['session', 'xyz']], 10_000);

console.log('has token:', ttlCache.has('token')); // true
console.log('has session:', ttlCache.has('session')); // true

// An empty array is a no-op.
const size = cache.size;
cache.setMany([]);
console.log('size unchanged after empty batch:', cache.size === size); // true

Try it

Builder

LruCache.builder().withCapacity(2).withTtlMs(5_000).withPrefix('demo').build() constructs the cache through the fluent builder. Press Execute to watch set, get, and LRU eviction at capacity 2: reading a key promotes it, so the next insert evicts the other key. The final assertions confirm which keys survived.

Builder — fluent LRU cache construction
/** builder-cache — construct an LruCache via the fluent builder API. Run: npx tsx packages/cache/examples/builder-cache.ts */
import assert from 'node:assert/strict';

import { LruCache } from '../src/index.js';

// #region usage
// Build a capacity-2 cache with a 5-second TTL and a 'demo' key prefix.
const cache = LruCache.builder<string, number>()
  .withCapacity(2)
  .withTtlMs(5_000)
  .withPrefix('demo')
  .build();

console.log('builder-cache: cache constructed via fluent builder');
console.log('builder-cache: initial size:', cache.size);

// Set two entries — cache is now at capacity.
cache.set('a', 1);
console.log('builder-cache: set("a", 1) — size:', cache.size);

cache.set('b', 2);
console.log('builder-cache: set("b", 2) — size:', cache.size);

// Read 'a' to promote it to most-recently-used.
const hitA = cache.get('a');
console.log('builder-cache: get("a") —>', hitA);

// Insert a third key. Cache is at capacity; 'b' is least-recently-used and is evicted.
cache.set('c', 3);
console.log('builder-cache: set("c", 3) — size:', cache.size);

// 'b' was evicted; 'a' and 'c' survive.
const missB = cache.get('b');
console.log('builder-cache: get("b") after eviction —>', missB);
console.log('builder-cache: has("a"):', cache.has('a'));
console.log('builder-cache: has("b"):', cache.has('b'));
console.log('builder-cache: has("c"):', cache.has('c'));
// #endregion usage

assert.equal(hitA, 1);
assert.equal(missB, undefined);
assert.equal(cache.has('b'), false);
assert.equal(cache.has('a'), true);
assert.equal(cache.has('c'), true);
assert.equal(cache.size, 2);

console.log('builder-cache: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

TracingCache subclasses LruCache and overrides eight hooks: onHit, onMiss, onSet, onUpdate, onEvict, onExpire, onDelete, and onClear. With capacity=2, watch the event sequence: set a, set b, hit a, update a, evict b for capacity, miss b, delete c, set d, clear. A second TTL scenario shows expire firing before miss.

Observed cache — lifecycle hook trace
/** observedCache — subclass overrides emit console.log trace lines on every cache event. Run: npx tsx examples/observedCache.ts */

// #region usage
import { EventRecorder } from '@studnicky/errors/observers';
import assert from 'node:assert/strict';

import { LruCache } from '../src/index.js';

class TracingCache extends LruCache<string, number> {
  readonly #recorder = new EventRecorder<{ 'event': string; 'key'?: string }>();

  get events(): { 'event': string; 'key'?: string }[] { return this.#recorder.events; }

  constructor(options: { 'capacity': number; 'ttlMs'?: number }) {
    super(options);
  }

  protected override onHit(key: string, value: number): void {
    this.#recorder.record({ 'event': 'hit', 'key': key }, `[cache] hit   key=${key} value=${value}`);
  }

  protected override onMiss(key: string): void {
    this.#recorder.record({ 'event': 'miss', 'key': key }, `[cache] miss  key=${key}`);
  }

  protected override onSet(key: string): void {
    this.#recorder.record({ 'event': 'set', 'key': key }, `[cache] set   key=${key}`);
  }

  protected override onUpdate(key: string): void {
    this.#recorder.record({ 'event': 'update', 'key': key }, `[cache] update key=${key}`);
  }

  protected override onEvict(key: string, reason: 'capacity'): void {
    this.#recorder.record({ 'event': 'evict', 'key': key }, `[cache] evict key=${key} reason=${reason}`);
  }

  protected override onExpire(key: string): void {
    this.#recorder.record({ 'event': 'expire', 'key': key }, `[cache] expire key=${key}`);
  }

  protected override onDelete(key: string): void {
    this.#recorder.record({ 'event': 'delete', 'key': key }, `[cache] delete key=${key}`);
  }

  protected override onClear(count: number): void {
    this.#recorder.record({ 'event': 'clear' }, `[cache] clear  count=${count}`);
  }

  eventNames(): string[] {
    const result: string[] = [];
    for (const e of this.events) {
      result.push(e.event);
    }
    return result;
  }
}

// Capacity-2 cache; demonstrates set, hit, miss, update, evict
const cache = new TracingCache({ 'capacity': 2, 'ttlMs': 5_000 });

cache.set('a', 1);       // onSet(a)
cache.set('b', 2);       // onSet(b)
cache.get('a');           // onHit(a, 1)
cache.set('a', 99);      // onUpdate(a)
cache.set('c', 3);       // onEvict(b, capacity) then onSet(c)
cache.get('b');           // onMiss(b) — evicted
cache.delete('c');        // onDelete(c)
cache.set('d', 4);       // onSet(d)
cache.clear();            // onClear(2)

// TTL expiry scenario
const ttlCache = new TracingCache({ 'capacity': 10 });
ttlCache.events.length = 0;
ttlCache.set('ttl-key', 7, { 'ttlMs': 1 }); // 1 ms TTL
await new Promise<void>((resolve) => { setTimeout(resolve, 5); });
ttlCache.get('ttl-key'); // onExpire then onMiss
// #endregion usage

// ---- assertions ----

assert.deepEqual(cache.eventNames(), [
  'set',    // set('a', 1)
  'set',    // set('b', 2)
  'hit',    // get('a')
  'update', // set('a', 99) — existing key
  'evict',  // b evicted for capacity
  'set',    // set('c', 3)
  'miss',   // get('b') — was evicted
  'delete', // delete('c')
  'set',    // set('d', 4)
  'clear'   // clear()
]);

assert.deepEqual(ttlCache.eventNames(), ['set', 'expire', 'miss']);

console.log('observedCache: all assertions passed');
Output
Press Execute to run this example against the real library.

Observability hooks

LruCache exposes protected lifecycle hooks that a subclass can override to add logging, timing, or metrics without any changes to the caller. The base class never calls any logger or metrics library. All hooks are no-ops by default.

HookWhen it firesArgs
onHit(key, value)get() finds a live, non-expired entrykey: K, value: V
onMiss(key)get() returns undefined (key absent or entry expired)key: K
onSet(key)set() inserts a new keykey: K
onUpdate(key)set() overwrites a value for an existing keykey: K
onEvict(key, reason)An entry is removed to make room at capacitykey: K, reason: 'capacity'
onExpire(key)get() or has() encounters an entry past its TTL and lazily removes it — fires before onMisskey: K
onDelete(key)delete() removes an entry that existed — not called for absent keyskey: K
onClear(count)clear() empties the cachecount: number (entries present before wipe)
ts
import { EventRecorder } from '@studnicky/errors/observers';
import assert from 'node:assert/strict';

import { LruCache } from '../src/index.js';

class TracingCache extends LruCache<string, number> {
  readonly #recorder = new EventRecorder<{ 'event': string; 'key'?: string }>();

  get events(): { 'event': string; 'key'?: string }[] { return this.#recorder.events; }

  constructor(options: { 'capacity': number; 'ttlMs'?: number }) {
    super(options);
  }

  protected override onHit(key: string, value: number): void {
    this.#recorder.record({ 'event': 'hit', 'key': key }, `[cache] hit   key=${key} value=${value}`);
  }

  protected override onMiss(key: string): void {
    this.#recorder.record({ 'event': 'miss', 'key': key }, `[cache] miss  key=${key}`);
  }

  protected override onSet(key: string): void {
    this.#recorder.record({ 'event': 'set', 'key': key }, `[cache] set   key=${key}`);
  }

  protected override onUpdate(key: string): void {
    this.#recorder.record({ 'event': 'update', 'key': key }, `[cache] update key=${key}`);
  }

  protected override onEvict(key: string, reason: 'capacity'): void {
    this.#recorder.record({ 'event': 'evict', 'key': key }, `[cache] evict key=${key} reason=${reason}`);
  }

  protected override onExpire(key: string): void {
    this.#recorder.record({ 'event': 'expire', 'key': key }, `[cache] expire key=${key}`);
  }

  protected override onDelete(key: string): void {
    this.#recorder.record({ 'event': 'delete', 'key': key }, `[cache] delete key=${key}`);
  }

  protected override onClear(count: number): void {
    this.#recorder.record({ 'event': 'clear' }, `[cache] clear  count=${count}`);
  }

  eventNames(): string[] {
    const result: string[] = [];
    for (const e of this.events) {
      result.push(e.event);
    }
    return result;
  }
}

// Capacity-2 cache; demonstrates set, hit, miss, update, evict
const cache = new TracingCache({ 'capacity': 2, 'ttlMs': 5_000 });

cache.set('a', 1);       // onSet(a)
cache.set('b', 2);       // onSet(b)
cache.get('a');           // onHit(a, 1)
cache.set('a', 99);      // onUpdate(a)
cache.set('c', 3);       // onEvict(b, capacity) then onSet(c)
cache.get('b');           // onMiss(b) — evicted
cache.delete('c');        // onDelete(c)
cache.set('d', 4);       // onSet(d)
cache.clear();            // onClear(2)

// TTL expiry scenario
const ttlCache = new TracingCache({ 'capacity': 10 });
ttlCache.events.length = 0;
ttlCache.set('ttl-key', 7, { 'ttlMs': 1 }); // 1 ms TTL
await new Promise<void>((resolve) => { setTimeout(resolve, 5); });
ttlCache.get('ttl-key'); // onExpire then onMiss

The base class never calls any logger or metrics library. All hooks are no-ops by default.

API

ExportTypeDescription
LruCache<K, V>classLRU + TTL cache; generic key and value types
LruCacheBuilder<K, V>classFluent builder for LruCache; produced by LruCache.builder()
LruCacheOptionsTypetype{ capacity, prefix?, ttlMs? }

LruCache<K, V>

MemberSignatureDescription
createstatic create<K, V>(options): LruCache<K, V>Constructs a cache from options
builderstatic builder<K, V>(): LruCacheBuilder<K, V>Returns a fluent builder for constructing a cache
sizeget size(): numberCurrent entry count
get(key: K) => V | undefinedReturns value; promotes to MRU; evicts expired
set(key: K, value: V, ttlMs?: number) => voidStores value; evicts LRU tail when at capacity
setMany(entries: ReadonlyArray<readonly [K, V]>, ttlMs?: number) => voidInserts entries in argument order; last entry is MRU; empty array is a no-op
has(key: K) => booleanTrue if key exists and has not expired
delete(key: K) => booleanRemoves entry; returns whether it existed
clear() => voidRemoves all entries

LruCacheBuilder<K, V>

MemberSignatureDescription
withCapacity(value: number) => thisSets the cache capacity (required before build())
withTtlMs(value: number) => thisSets the default TTL in milliseconds
withPrefix(value: string) => thisSets the key namespace prefix
build() => LruCache<K, V>Constructs the cache; throws if capacity was not set

Source on GitHub