keep-cache
v1.0.1
Published
Tiny, dependency-free typed cache with pluggable eviction policy (LRU / LFU), TTL, size limits and eviction callbacks. Works in browsers, Node, Deno, Bun and Web Workers.
Maintainers
Readme
keep-cache
A tiny, dependency-free typed cache with a pluggable eviction policy — LRU or LFU — plus TTL, size limits and eviction callbacks, in 1.73 kB gzipped for the LRU entry point.
npm install keep-cacheRuns anywhere JavaScript does: browsers, Node, Deno, Bun and Web Workers. No Node built-ins, no DOM APIs, no runtime dependencies, no promises — the whole API is synchronous.
Quick start
import { LRUCache } from 'keep-cache/lru';
const sessions = new LRUCache<string, { userId: number }>({
max: 500, // at most 500 entries
ttl: 5 * 60_000, // each one expires after five minutes
updateAgeOnGet: true, // ...unless it keeps being read
onEvict: ({ key, reason }) => console.log(`dropped ${key} (${reason})`),
});
sessions.set('sess_1', { userId: 42 });
sessions.get('sess_1'); // → { userId: 42 }
sessions.get('sess_9', { userId: 0 }); // → { userId: 0 }, the fallback
sessions.has('sess_1'); // → trueImport a policy directly (keep-cache/lru or keep-cache/lfu) and the other one never reaches your bundle. If you would rather choose at runtime, the root export dispatches on an option:
import { Cache } from 'keep-cache';
const cache = new Cache<string, Uint8Array>({ policy: 'lfu', max: 1000 });API
new Cache(options?) · new LRUCache(options?) · new LFUCache(options?)
All three share one surface. Cache accepts an extra policy option and forwards everything to one of the other two; LRUCache and LFUCache are the policies themselves.
Keys are not constrained to strings — objects, symbols, numbers and tuples all work, compared by Map identity semantics.
Options
| Option | Type | Default | Description |
| ----------------- | ------------------------ | ------- | ----------------------------------------------------------------------------------------- |
| max | number | — | Maximum number of entries. Must be a positive integer. |
| maxSize | number | — | Maximum total weight. Needs sizeCalculation, or a size on every set(). |
| sizeCalculation | (value, key) => number | — | Computes an entry's weight. Called once per set(); the result is memoised on the entry. |
| ttl | number | 0 | Default time-to-live in milliseconds. 0 means entries never expire. |
| updateAgeOnGet | boolean | false | Restart an entry's expiry countdown whenever get() returns it. |
| ttlAutopurge | boolean | false | Remove expired entries on a timer instead of lazily on access. |
| policy | 'lru' \| 'lfu' | 'lru' | Cache only. Which policy to construct. |
| onEvict | (event) => void | — | Called after an entry has been removed, for any reason. |
Every numeric option is validated in the constructor. max must be a positive integer, maxSize a positive finite number, ttl a non-negative finite number; anything else throws a TypeError immediately rather than misbehaving later.
sizeCalculation is only consulted when maxSize is set. Without maxSize no weights are tracked at all and calculatedSize stays 0.
Methods
get(key): V | undefined · get(key, fallback): V
Returns the value, counting as an access for the policy. With a second argument, returns that fallback instead of undefined when the key is missing or expired — and the return type narrows to V accordingly.
Reading an expired entry removes it and fires onEvict with reason 'ttl'.
peek(key): V | undefined
Reads without affecting recency or frequency, and without refreshing the TTL under updateAgeOnGet. Expired entries read as undefined but are left in place for purgeStale() to collect.
set(key, value, options?): this
Stores a value and returns the cache, so calls chain. options:
ttl— per-entry TTL in milliseconds, overriding the constructor default. Pass0to make this one entry permanent.size— an explicit weight for this entry, overridingsizeCalculation.
Overwriting an existing key fires onEvict with reason 'replace' and the old value, keeps the key's slot, and counts as an access.
If the new value is heavier than the old one and that pushes the cache over maxSize, the room is made before the write lands, so other entries are evicted first and the 'replace' event closes the sequence. The key being written is never the one evicted to pay for its own new weight — set() either stores the value or throws, and once measure has accepted a weight the entry is guaranteed to be resident when set() returns.
Writing over a key that has expired but not yet been purged is an insert, not a replace: has() and get() already report that key as absent, so the dead entry is released with reason 'ttl' and the new value starts a fresh entry.
Throws a TypeError, without touching the cache, if the entry's own weight exceeds maxSize, or if maxSize is in use and no weight is available for the entry.
has(key): boolean
True if the key is present and not expired. Does not affect recency or frequency, and does not remove an expired entry.
delete(key): boolean
Removes the key and fires onEvict with reason 'delete'. Returns whether a live entry was removed — deleting an entry that had already expired purges it with reason 'ttl' and returns false, matching what has() would have said about it.
clear(): void
Removes everything, firing onEvict with reason 'clear' once per entry.
purgeStale(): number
Removes every expired entry now and returns how many were removed. Only useful with lazy expiry — under ttlAutopurge the timer does this for you.
size: number (readonly)
Number of live entries. Entries that have expired but not yet been purged are not counted. This is O(1) unless something has actually expired since the last purge, in which case it is O(n).
calculatedSize: number (readonly)
Sum of the weights of all live entries, or 0 when maxSize is not in use. Like size, it excludes entries that have expired but not yet been purged, with the same complexity.
keys() · values() · entries() · [Symbol.iterator]()
Lazy iterators, ordered most-recently-used first under LRU and most-frequently-used first under LFU. Expired entries are skipped without being removed, so iterating never fires onEvict.
for (const [key, value] of cache) {
/* … */
}
const snapshot = Object.fromEntries(cache);dispose(): void
Stops the ttlAutopurge timer. Idempotent, and a no-op when autopurge is off. The timer is unrefed wherever the runtime supports it (Node, Bun), so it never holds a process open by itself — dispose() is for when you want the cache to stop doing anything at all.
Eviction events
onEvict receives { key, value, reason } after the entry has been removed from every internal structure, so calling set(), delete() or clear() from inside it is safe.
| reason | Fired when |
| ------------ | ---------------------------------------------------- |
| 'capacity' | the max entry count was exceeded |
| 'size' | the maxSize total weight was exceeded |
| 'ttl' | the entry expired and was purged |
| 'delete' | delete() removed a live entry |
| 'clear' | clear() removed it |
| 'replace' | set() overwrote the value — value is the old one |
TTL
Expiry is lazy by default: each entry stores an absolute expiry timestamp, checked by get, peek, has and iteration. No timers, nothing to leak, and nothing to clean up.
ttlAutopurge: true adds proactive collection. It uses exactly one timer, armed for the nearest expiry, re-armed after each purge and whenever a set introduces an earlier one — never a timer per entry.
Choosing a policy
LRU is the right default. It is cheaper per operation, it needs no tuning, and it adapts immediately: one pass over a new working set is enough for the cache to hold the new hot data. Reach for it when access is bursty or phase-based — a user scrolls through one screen, then another; a build touches one directory, then the next. It is also the policy you want when a single scan must not be able to poison the cache for long.
LFU wins when popularity is stable and skewed. If 5% of your keys serve 80% of the reads, and that stays true over hours, LFU keeps exactly those 5% resident where LRU keeps evicting them behind whatever was touched most recently. Classic fits: a CDN-ish asset cache, a lookup table of reference data, compiled templates or query plans.
The failure mode LFU is known for, and how this package handles it. A naive LFU never forgets. A key that was hammered an hour ago carries a count no newcomer can reach, so it sits in the cache forever, immune to eviction. keep-cache counters that by halving every entry's frequency (floored at 1) after a fixed number of operations — max * 10, or 1000 when max is not set. Relative ordering survives the halving, but a key that stops being read decays back into eviction range within a few rounds.
That decay is a heuristic, not a guarantee. The threshold is an internal constant, not an option, and it is deliberately not tuned to your workload. If you need bounded, predictable admission behaviour under a shifting working set, use LRU — or put a TTL on the entries so staleness is time-bounded rather than access-bounded.
Comparison
| | keep-cache | lru-cache 11.5.2 | quick-lru 7.3.0 | tiny-lru 13.0.0 |
| ------------------------- | ---------------------------------------------------- | -------------------------------------------- | -------------------------------------------------- | ------------------------------------------ |
| Policies | LRU and LFU | LRU | LRU¹ | LRU |
| Min+gzip² | 1.73 kB (lru)2.02 kB (lfu)2.26 kB (both) | 5.81 kB | 1.13 kB | 1.57 kB |
| TTL | yes, lazy or autopurge | yes, richest of the four | yes (maxAge) | yes |
| Weight limits (maxSize) | yes | yes | no | no |
| Eviction callback | onEvict, 6 reasons, every removal path | dispose/disposeAfter, 5 reasons | onEviction, but not for delete()/clear() | none³ |
| Non-string keys | yes | yes | yes | no⁴ |
| Module formats | ESM + CJS | ESM + CJS | ESM only | ESM + CJS |
| Runtime dependencies | 0 | 0 | 0 | 0 |
¹ quick-lru's two-segment design holds between maxSize and 2 × maxSize items, so its bound is soft.
² All six measured in a single size-limit run (esbuild, minified + gzipped) against each package's published ESM build, at the versions listed (measured August 2026).
³ tiny-lru returns the displaced item from setWithEvicted() instead of invoking a callback.
⁴ tiny-lru stores entries on a plain object, so object keys are stringified and collide: set({id: 1}, 'a') and set({id: 2}, 'b') are the same entry.
lru-cache is faster than keep-cache on raw operations, and it is the better choice if throughput is what you are optimising. It uses typed-array indices instead of a Map and is far more heavily tuned. In a local mixed set/get benchmark it ran roughly 1.2–1.3× more operations per second than keep-cache; that is a single measurement on one machine and one workload, not a published benchmark, so treat it as a direction rather than a figure. lru-cache also does considerably more than this package — fetch()/memo(), stale-while-revalidate, abort signals — and the size difference above is largely those features, not overhead.
What keep-cache offers in exchange is a quarter of the bytes, a second eviction policy behind the same API, and a surface small enough to read in one sitting.
Caveats
The system clock matters. Expiry uses Date.now(), not a monotonic clock, because performance.now() has no shared origin across the runtimes this package targets. If the system clock jumps — an NTP correction, a VM resuming from a snapshot, a user changing the date — entries can expire early or late by exactly that jump. TTLs of seconds to minutes are unaffected in practice; hour-scale TTLs on machines with unreliable clocks are not.
onEvict must not throw. The callback is deliberately not wrapped in try/catch: swallowing an exception in a resource-release callback turns a loud bug into an unreproducible one. An exception thrown from onEvict propagates out of whichever call triggered it — set(), delete(), clear() or purgeStale() — and the entry stays removed. Do your own error handling inside the callback.
LFU decay is a heuristic. Halving frequencies on a fixed operation count is a coarse approximation of aging. It is not a windowed frequency estimate, the threshold is not configurable, and there is no admission filter, so a burst of one-hit keys can still push out a merely-warm key. See Choosing a policy.
Expired entries linger until something needs their room. Under lazy expiry a dead entry stays in memory until it is read, purged, or stands between a limit and an insert — so a cache holding keys nobody touches again keeps their values alive. size and calculatedSize both exclude them, and neither max nor maxSize will evict a live entry while a dead one could go instead, but the memory is only released on the next purge. Enable ttlAutopurge, or call purgeStale(), if values must be dropped promptly rather than eventually.
License
MIT
