Implementar una cache LRU en Node.js
Construye una cache least-recently-used en Node.js con operaciones get y set O(1) usando un Map y lista doblemente enlazada
Una cache LRU (Least Recently Used) evicta la entrada accedida mas antigua cuando alcanza su capacidad. Esto mantiene datos calientes en memoria mientras limita el uso de memoria. El Map de JavaScript preserva el orden de insercion, lo que lo hace ideal para LRU — re-insertar una clave la mueve al final, por lo que la primera entrada es siempre la menos recientemente usada.
Cuando Usar Esto
-
For alternatives, see Multi-Level Cache with In-Memory L1 and Redis L2.
-
Cachear computaciones costosas o consultas a base de datos dentro de un solo proceso
-
Rate limiting o deduplicacion donde entradas viejas deben expirar primero
-
Escenarios donde Redis es excesivo pero
Mapsolo carece de eviccion Ver también Debounce y Throttle.
Requisitos Previos
- Node.js 18+
- Sin dependencias externas requeridas
Solucion
1. Implementar la cache LRU
// lru-cache.ts
export class LRUCache<K, V> {
private cache: Map<K, V>;
private readonly capacity: number;
constructor(capacity: number) {
if (capacity <= 0) {
throw new Error("Capacity must be positive");
}
this.capacity = capacity;
this.cache = new Map();
}
get(key: K): V | undefined {
if (!this.cache.has(key)) {
return undefined;
}
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, value);
}
has(key: K): boolean {
return this.cache.has(key);
}
delete(key: K): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
entries(): IterableIterator<[K, V]> {
return this.cache.entries();
}
}
2. Agregar soporte TTL
// lru-cache-ttl.ts
interface CacheEntry<V> {
value: V;
expiresAt: number;
}
export class TTLCache<K, V> {
private cache: Map<K, CacheEntry<V>>;
private readonly capacity: number;
private readonly defaultTtl: number;
private cleanupTimer: NodeJS.Timeout | null = null;
constructor(capacity: number, defaultTtl: number = 300_000) {
this.capacity = capacity;
this.defaultTtl = defaultTtl;
this.cache = new Map();
}
get(key: K): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
set(key: K, value: V, ttl: number = this.defaultTtl): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
value,
expiresAt: Date.now() + ttl,
});
}
startCleanup(interval: number = 60_000): void {
if (this.cleanupTimer) return;
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
this.cleanupTimer.unref();
}
stopCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (now > entry.expiresAt) {
this.cache.delete(key);
} else {
break;
}
}
}
get size(): number {
return this.cache.size;
}
clear(): void {
this.cache.clear();
}
}
3. Usar la cache
// usage.ts
import { LRUCache } from './lru-cache';
const cache = new LRUCache<string, any>(100);
cache.set("user:1", { id: 1, name: "Alice" });
cache.set("user:2", { id: 2, name: "Bob" });
console.log(cache.get("user:1")); // { id: 1, name: "Alice" }
console.log(cache.size); // 2
// Agregar mas alla de la capacidad evicta el menos recientemente usado
for (let i = 3; i <= 101; i++) {
cache.set(`user:${i}`, { id: i });
}
console.log(cache.has("user:2")); // false — evicted
console.log(cache.has("user:1")); // true — accedido recientemente
4. Envolver una funcion con caching
// memoize.ts
import { LRUCache } from './lru-cache';
export function memoize<Args extends any[], R>(
fn: (...args: Args) => R,
capacity: number = 100,
keyFn: (...args: Args) => string = (...args) => JSON.stringify(args),
): (...args: Args) => R {
const cache = new LRUCache<string, R>(capacity);
return (...args: Args): R => {
const key = keyFn(...args);
const cached = cache.get(key);
if (cached !== undefined) {
return cached;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// Uso
const expensiveCompute = memoize(
(n: number) => {
console.log(`Computing for ${n}...`);
return n * n;
},
50,
);
expensiveCompute(5); // "Computing for 5..." -> 25
expensiveCompute(5); // 25 (desde cache)
Como Funciona
Mappreserva el orden de insercion — las claves se iteran en el orden en que se agregaron.delete+setmueve una clave al final (mas recientemente usada).- Eviccion — cuando
size >= capacity, la primera clave dekeys().next()es la menos recientemente usada. Eliminala antes de insertar la nueva entrada. - Entradas TTL envuelven valores con un timestamp
expiresAt.getverifica la expiracion y elimina entradas obsoletas al acceder. unref()en el timer de limpieza evita que el timer mantenga vivo el proceso de Node.js.
Variantes
Cache LRU asincrona
Para cachear operaciones asincronas (llamadas a API, consultas DB):
export class AsyncLRUCache<K, V> {
private cache: Map<K, { promise: Promise<V>; expiresAt: number }>;
private readonly capacity: number;
private readonly ttl: number;
constructor(capacity: number, ttl: number = 300_000) {
this.capacity = capacity;
this.ttl = ttl;
this.cache = new Map();
}
async get(key: K, loader: () => Promise<V>): Promise<V> {
const entry = this.cache.get(key);
if (entry && Date.now() < entry.expiresAt) {
this.cache.delete(key);
this.cache.set(key, entry);
return entry.promise;
}
const promise = loader();
this.cache.set(key, { promise, expiresAt: Date.now() + this.ttl });
if (this.cache.size > this.capacity) {
const oldest = this.cache.keys().next().value;
if (oldest !== undefined) this.cache.delete(oldest);
}
return promise;
}
}
Usar el paquete lru-cache
Para uso en produccion, el paquete npm lru-cache esta bien probado y rico en features:
npm install lru-cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({
max: 500,
ttl: 300_000,
updateAgeOnGet: true,
dispose: (value, key, reason) => {
console.log(`Evicted ${key}: ${reason}`);
},
});
Mejores Practicas
- Establece una capacidad razonable — muy grande desperdicia memoria, muy pequena causa evicciones frecuentes
- Usa TTL para datos propensos a obsolescencia — combina eviccion LRU con expiracion TTL para lo mejor de ambos mundos
- Llama
unref()en timers — los timers de limpieza no deben impedir la salida del proceso - Mide el hit rate — un hit rate bajo significa que la cache es demasiado pequena o el patron de acceso no es repetible
Errores Comunes
- No eliminar antes de re-setear —
Map.seten una clave existente actualiza el valor pero NO cambia el orden de iteracion; debes hacerdeleteprimero - Almacenar objetos grandes — la cache mantiene referencias; los objetos grandes permanecen en memoria hasta ser evicted
- Usar LRU entre procesos — las caches en memoria son por proceso; usa Redis para cache compartida
- Olvidar manejar valores
undefined— si una funcion retorna legitimoundefined, la cache no puede distinguir entre “miss” y “undefined cacheado”
Preguntas frecuentes
Cual es la complejidad temporal de get y set?
O(1). Las operaciones de Map son de tiempo constante, y delete + set para reordenar tambien es O(1).
Debo usar esto o el paquete npm lru-cache?
Usa el paquete npm para produccion — maneja casos extremos, tiene callbacks dispose y soporta TTL con eviccion perezosa.
Puedo usar WeakMap en lugar de Map?
No. WeakMap no soporta iteracion, que es requerida para la eviccion LRU.
Como monitoreo el hit rate de la cache?
Rastrea hits y misses en get: incrementa un contador y registra el ratio periodicamente.
Recursos Relacionados
Caching con Redis
Cómo implementar caching de aplicaciones usando Redis para rendimiento y escalabilidad.
RecipeCache de resultados de funciones con Redis y TTL en Python
Construye un decorador de Python que cachea resultados de funciones en Redis con TTL configurable, generacion de claves e invalidacion de cache
PatternPatrón Cache-Aside
Carga datos en el caché bajo demanda desde el almacenamiento principal. Un patrón de caché que da a la aplicación control total sobre qué y cuándo cachear.
RecipeCache multi-nivel con L1 en memoria y L2 en Redis
Implementa una cache de dos niveles combinando L1 en memoria y L2 en Redis para lecturas de baja latencia con consistencia entre instancias
RecipeConfigurar Caffeine Cache en Java con Politicas de Eviction
Configura Caffeine cache en una aplicacion Java con politicas de eviction por tamano, tiempo y peso para caching local de alto rendimiento.