tiny-lru
Version:
A fast, lightweight LRU (Least Recently Used) cache for JavaScript with O(1) operations and optional TTL support.
680 lines (589 loc) • 15.5 kB
JavaScript
/**
* tiny-lru
*
* @copyright 2026 Jason Mulligan <jason.mulligan@avoidwork.com>
* @license BSD-3-Clause
* @version 13.0.0
*/
/**
* A high-performance Least Recently Used (LRU) cache implementation with optional TTL support.
* Items are automatically evicted when the cache reaches its maximum size,
* removing the least recently used items first. All core operations (get, set, delete) are O(1).
*
* @class LRU
*/
class LRU {
#stats;
#onEvict;
/**
* Creates a new LRU cache instance.
* Note: Constructor does not validate parameters. Use lru() factory function for parameter validation.
*
* @constructor
* @param {number} [max=0] - Maximum number of items to store. 0 means unlimited.
* @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration.
* @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set().
*/
constructor(max = 0, ttl = 0, resetTTL = false) {
this.first = null;
this.items = Object.create(null);
this.last = null;
this.max = max;
this.resetTTL = resetTTL;
this.size = 0;
this.ttl = ttl;
this.#stats = { hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0 };
this.#onEvict = null;
}
/**
* Removes all items from the cache.
*
* @returns {LRU} The LRU instance for method chaining.
*/
clear() {
for (let x = this.first; x !== null; ) {
const next = x.next;
x.prev = null;
x.next = null;
x = next;
}
this.first = null;
this.items = Object.create(null);
this.last = null;
this.size = 0;
this.#stats.hits = 0;
this.#stats.misses = 0;
this.#stats.sets = 0;
this.#stats.deletes = 0;
this.#stats.evictions = 0;
return this;
}
/**
* Removes an item from the cache by key.
*
* @param {string} key - The key of the item to delete.
* @returns {LRU} The LRU instance for method chaining.
*/
delete(key) {
const item = this.items[key];
if (item !== undefined) {
delete this.items[key];
this.size--;
this.#stats.deletes++;
this.#unlink(item);
item.prev = null;
item.next = null;
}
return this;
}
/**
* Returns an array of [key, value] pairs for the specified keys.
* When no keys provided, returns all entries in LRU order.
* When keys provided, order matches the input array.
*
* @param {string[]} [keys=this.keys()] - Array of keys to get entries for. Defaults to all keys.
* @returns {Array<Array<*>>} Array of [key, value] pairs.
*/
entries(keys) {
if (keys === undefined) {
keys = this.keys();
}
const result = Array.from({ length: keys.length });
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const item = this.items[key];
result[i] = [key, item !== undefined ? item.value : undefined];
}
return result;
}
/**
* Removes the least recently used item from the cache.
*
* @returns {LRU} The LRU instance for method chaining.
*/
evict() {
if (this.size === 0) {
return this;
}
const item = this.first;
delete this.items[item.key];
this.#stats.evictions++;
if (--this.size === 0) {
this.first = null;
this.last = null;
} else {
this.#unlink(item);
}
item.prev = null;
item.next = null;
if (this.#onEvict !== null) {
this.#onEvict({
key: item.key,
value: item.value,
expiry: item.expiry,
});
}
return this;
}
/**
* Returns the expiration timestamp for a given key.
*
* @param {string} key - The key to check expiration for.
* @returns {number|undefined} The expiration timestamp in milliseconds, or undefined if key doesn't exist.
*/
expiresAt(key) {
const item = this.items[key];
return item !== undefined ? item.expiry : undefined;
}
/**
* Checks if an item has expired.
*
* @param {Object} item - The cache item to check.
* @returns {boolean} True if the item has expired, false otherwise.
* @private
*/
#isExpired(item) {
if (this.ttl === 0 || item.expiry === 0) {
return false;
}
return item.expiry <= Date.now();
}
/**
* Retrieves a value from the cache by key without updating LRU order.
* Note: Does not perform TTL checks or remove expired items.
*
* @param {string} key - The key to retrieve.
* @returns {*} The value associated with the key, or undefined if not found.
*/
peek(key) {
const item = this.items[key];
return item !== undefined ? item.value : undefined;
}
/**
* Retrieves a value from the cache by key. Updates the item's position to most recently used.
*
* @param {string} key - The key to retrieve.
* @returns {*} The value associated with the key, or undefined if not found or expired.
*/
get(key) {
const item = this.items[key];
if (item !== undefined) {
if (!this.#isExpired(item)) {
this.moveToEnd(item);
this.#stats.hits++;
return item.value;
}
this.delete(key);
this.#stats.misses++;
return undefined;
}
this.#stats.misses++;
return undefined;
}
/**
* Checks if a key exists in the cache.
*
* @param {string} key - The key to check for.
* @returns {boolean} True if the key exists and is not expired, false otherwise.
*/
has(key) {
const item = this.items[key];
return item !== undefined && !this.#isExpired(item);
}
/**
* Unlinks an item from the doubly-linked list.
* Updates first/last pointers if needed.
* Does NOT clear the item's prev/next pointers or delete from items map.
*
* @private
*/
#unlink(item) {
if (item.prev !== null) {
item.prev.next = item.next;
}
if (item.next !== null) {
item.next.prev = item.prev;
}
if (this.first === item) {
this.first = item.next;
}
if (this.last === item) {
this.last = item.prev;
}
}
/**
* Efficiently moves an item to the end of the LRU list (most recently used position).
* This is an internal optimization method that avoids the overhead of the full set() operation
* when only LRU position needs to be updated.
*
* @param {Object} item - The cache item with prev/next pointers to reposition.
* @private
*/
moveToEnd(item) {
if (this.last === item) {
return;
}
this.#unlink(item);
item.prev = this.last;
item.next = null;
this.last.next = item;
this.last = item;
}
/**
* Returns an array of all keys in the cache, ordered from least to most recently used.
*
* @returns {string[]} Array of keys in LRU order.
*/
keys() {
const result = Array.from({ length: this.size });
let x = this.first;
let i = 0;
while (x !== null) {
result[i++] = x.key;
x = x.next;
}
return result;
}
/**
* Sets a value in the cache and returns any evicted item.
*
* @param {string} key - The key to set.
* @param {*} value - The value to store.
* @returns {Object|null} The evicted item (if any) with shape {key, value, expiry}, or null.
*/
setWithEvicted(key, value) {
let evicted = null;
let item = this.items[key];
if (item !== undefined) {
item.value = value;
if (this.resetTTL) {
item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;
}
this.moveToEnd(item);
} else {
if (this.max > 0 && this.size === this.max) {
evicted = {
key: this.first.key,
value: this.first.value,
expiry: this.first.expiry,
};
this.evict();
}
item = this.items[key] = {
expiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,
key: key,
prev: this.last,
next: null,
value,
};
if (++this.size === 1) {
this.first = item;
} else {
this.last.next = item;
}
this.last = item;
}
this.#stats.sets++;
return evicted;
}
/**
* Sets a value in the cache. Updates the item's position to most recently used.
*
* @param {string} key - The key to set.
* @param {*} value - The value to store.
* @returns {LRU} The LRU instance for method chaining.
*/
set(key, value) {
let item = this.items[key];
if (item !== undefined) {
item.value = value;
if (this.resetTTL) {
item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;
}
this.moveToEnd(item);
} else {
if (this.max > 0 && this.size === this.max) {
this.evict();
}
item = this.items[key] = {
expiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,
key: key,
prev: this.last,
next: null,
value,
};
if (++this.size === 1) {
this.first = item;
} else {
this.last.next = item;
}
this.last = item;
}
this.#stats.sets++;
return this;
}
/**
* Returns an array of all values in the cache for the specified keys.
* When no keys provided, returns all values in LRU order.
* When keys provided, order matches the input array.
*
* @param {string[]} [keys] - Array of keys to get values for. Defaults to all keys.
* @returns {Array<*>} Array of values corresponding to the keys.
*/
values(keys) {
if (keys === undefined) {
const result = Array.from({ length: this.size });
let i = 0;
for (let x = this.first; x !== null; x = x.next) {
result[i++] = x.value;
}
return result;
}
const result = Array.from({ length: keys.length });
for (let i = 0; i < keys.length; i++) {
const item = this.items[keys[i]];
result[i] = item !== undefined ? item.value : undefined;
}
return result;
}
/**
* Iterate over cache items in LRU order (least to most recent).
* Note: This method directly accesses items from the linked list without calling
* get() or peek(), so it does not update LRU order or check TTL expiration during iteration.
*
* @param {function(*, any, LRU): void} callback - Function to call for each item. Signature: callback(value, key, cache)
* @param {Object} [thisArg] - Value to use as `this` when executing callback.
* @returns {LRU} The LRU instance for method chaining.
*/
forEach(callback, thisArg) {
for (let x = this.first; x !== null; x = x.next) {
callback.call(thisArg, x.value, x.key, this);
}
return this;
}
/**
* Batch retrieve multiple items.
*
* @param {string[]} keys - Array of keys to retrieve.
* @returns {Object} Object mapping keys to values (undefined for missing/expired keys).
*/
getMany(keys) {
const result = Object.create(null);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
result[key] = this.get(key);
}
return result;
}
/**
* Batch existence check - returns true if ALL keys exist.
*
* @param {string[]} keys - Array of keys to check.
* @returns {boolean} True if all keys exist and are not expired.
*/
hasAll(keys) {
for (let i = 0; i < keys.length; i++) {
if (!this.has(keys[i])) {
return false;
}
}
return true;
}
/**
* Batch existence check - returns true if ANY key exists.
*
* @param {string[]} keys - Array of keys to check.
* @returns {boolean} True if any key exists and is not expired.
*/
hasAny(keys) {
for (let i = 0; i < keys.length; i++) {
if (this.has(keys[i])) {
return true;
}
}
return false;
}
/**
* Remove expired items without affecting LRU order.
* Unlike get(), this does not move items to the end.
*
* @returns {number} Number of expired items removed.
*/
cleanup() {
if (this.ttl === 0 || this.size === 0) {
return 0;
}
let removed = 0;
for (let x = this.first; x !== null; ) {
const next = x.next;
if (this.#isExpired(x)) {
const key = x.key;
if (this.items[key] !== undefined) {
delete this.items[key];
this.size--;
removed++;
this.#unlink(x);
x.prev = null;
x.next = null;
}
}
x = next;
}
if (removed > 0) {
this.#rebuildList();
}
return removed;
}
/**
* Serialize cache to JSON-compatible format.
*
* @returns {Array<{key: any, value: *, expiry: number}>} Array of cache items.
*/
toJSON() {
const result = [];
for (let x = this.first; x !== null; x = x.next) {
result.push({
key: x.key,
value: x.value,
expiry: x.expiry,
});
}
return result;
}
/**
* Get cache statistics.
*
* @returns {Object} Statistics object with hits, misses, sets, deletes, evictions counts.
*/
stats() {
return { ...this.#stats };
}
/**
* Register callback for evicted items.
*
* @param {function(Object): void} callback - Function called when item is evicted. Receives {key, value, expiry}.
* @returns {LRU} The LRU instance for method chaining.
*/
onEvict(callback) {
if (typeof callback !== "function") {
throw new TypeError("onEvict callback must be a function");
}
this.#onEvict = callback;
return this;
}
/**
* Get counts of items by TTL status.
*
* @returns {Object} Object with valid, expired, and noTTL counts.
*/
sizeByTTL() {
if (this.ttl === 0) {
return { valid: this.size, expired: 0, noTTL: this.size };
}
const now = Date.now();
let valid = 0;
let expired = 0;
let noTTL = 0;
for (let x = this.first; x !== null; x = x.next) {
if (x.expiry === 0) {
noTTL++;
valid++;
} else if (x.expiry > now) {
valid++;
} else {
expired++;
}
}
return { valid, expired, noTTL };
}
/**
* Get keys filtered by TTL status.
*
* @returns {Object} Object with valid, expired, and noTTL arrays of keys.
*/
keysByTTL() {
if (this.ttl === 0) {
return { valid: this.keys(), expired: [], noTTL: this.keys() };
}
const now = Date.now();
const valid = [];
const expired = [];
const noTTL = [];
for (let x = this.first; x !== null; x = x.next) {
if (x.expiry === 0) {
valid.push(x.key);
noTTL.push(x.key);
} else if (x.expiry > now) {
valid.push(x.key);
} else {
expired.push(x.key);
}
}
return { valid, expired, noTTL };
}
/**
* Get values filtered by TTL status.
*
* @returns {Object} Object with valid, expired, and noTTL arrays of values.
*/
valuesByTTL() {
const keysByTTL = this.keysByTTL();
return {
valid: this.values(keysByTTL.valid),
expired: this.values(keysByTTL.expired),
noTTL: this.values(keysByTTL.noTTL),
};
}
/**
* Rebuild the doubly-linked list after cleanup by deleting expired items.
* This removes nodes that were deleted during cleanup.
*
* @private
*/
#rebuildList() {
if (this.size === 0) {
this.first = null;
this.last = null;
return;
}
const keys = this.keys();
this.first = null;
this.last = null;
for (let i = 0; i < keys.length; i++) {
const item = this.items[keys[i]];
if (item !== null && item !== undefined) {
if (this.first === null) {
this.first = item;
item.prev = null;
} else {
item.prev = this.last;
this.last.next = item;
}
item.next = null;
this.last = item;
}
}
}
}
/**
* Factory function to create a new LRU cache instance with parameter validation.
*
* @function lru
* @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size.
* @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration.
* @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set().
* @returns {LRU} A new LRU cache instance.
* @throws {TypeError} When parameters are invalid (negative numbers or wrong types).
*/
function lru(max = 1000, ttl = 0, resetTTL = false) {
if (isNaN(max) || max < 0) {
throw new TypeError("Invalid max value");
}
if (isNaN(ttl) || ttl < 0) {
throw new TypeError("Invalid ttl value");
}
if (typeof resetTTL !== "boolean") {
throw new TypeError("Invalid resetTTL value");
}
return new LRU(max, ttl, resetTTL);
}export{LRU,lru};