Back

Patrones Prácticos de Memoización en JavaScript

Patrones Prácticos de Memoización en JavaScript

Has perfilado tu aplicación y encontraste una función ejecutándose miles de veces con entradas idénticas. La memoización parece la solución obvia. Pero antes de envolver todo en una caché, debes saber esto: la memoización mal implementada crea bugs más difíciles de encontrar que los problemas de rendimiento con los que empezaste.

Este artículo cubre patrones prácticos de memoización en JavaScript, trampas comunes de memoización que encuentran los desarrolladores de JavaScript, y cómo aplicar estas técnicas de forma segura en código de producción—incluyendo memoización asíncrona y mejores prácticas de useMemo en React.

Puntos Clave

  • La memoización almacena en caché los resultados de funciones basándose en argumentos, intercambiando memoria por velocidad—pero solo funciona de manera confiable con funciones puras y argumentos primitivos.
  • Las referencias a objetos causan aciertos de caché obsoletos cuando los datos cambian; usa bibliotecas como fast-memoize para claves personalizadas o limítate a primitivos.
  • Las cachés sin límites generan fugas de memoria en aplicaciones de larga ejecución; implementa desalojo LRU, expiración TTL, o delimita las cachés a ciclos de vida de componentes.
  • La memoización asíncrona requiere almacenar promesas inmediatamente en caché y eliminar entradas fallidas para prevenir solicitudes duplicadas y habilitar reintentos.
  • El useMemo de React es una optimización específica, no un valor predeterminado—perfila primero y solo aplícalo cuando los cálculos sean mediblemente lentos.

Qué Hace Realmente la Memoización

La memoización almacena en caché los resultados de funciones basándose en sus argumentos. Llama a la función nuevamente con las mismas entradas, y obtienes el resultado en caché en lugar de recalcular.

JavaScript no tiene memoización incorporada. TC39 ha discutido propuestas (por ejemplo, Function.prototype.memo), pero nada está listo para producción todavía (propuesta). Tendrás que implementar esto tú mismo o usar una biblioteca.

Aquí hay un patrón básico para funciones de un solo argumento:

function memoize(fn) {
  const cache = new Map()
  return (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    const result = fn(...args)
    cache.set(key, result)
    return result
  }
}

Esto funciona para argumentos primitivos. Se rompe de formas sutiles para todo lo demás.

El Problema de las Referencias a Objetos

Los objetos se almacenan en caché por referencia, no por valor. Esto atrapa constantemente a los desarrolladores:

const memoizedFn = memoize(processData)

const config = { threshold: 10 }
memoizedFn(config) // Calcula
config.threshold = 20
memoizedFn(config) // Devuelve resultado en caché obsoleto

La misma referencia significa acierto de caché, aunque los datos hayan cambiado.

Algunos desarrolladores intentan arreglar esto usando JSON.stringify(args) como clave de caché. Eso puede funcionar para datos simples, pero falla con referencias circulares, descarta funciones y símbolos, y puede ser lento para objetos grandes.

La solución: Solo memoiza funciones con argumentos primitivos, o usa una biblioteca como fast-memoize que soporta resolvedores/serializadores de claves personalizados para casos más complejos.

Cuándo la Memoización Causa Problemas

Funciones Impuras

Memoizar funciones impuras crea problemas imposibles de depurar:

// Nunca memoices esto para almacenamiento en caché a largo plazo
const getData = memoize(() => {
  return fetch('/api/data').then(r => r.json())
})

La primera llamada almacena la promesa en caché. Cada llamada subsiguiente devuelve esa misma promesa—incluso si los datos del servidor cambiaron.

La memoización asíncrona solo es segura cuando intencionalmente quieres deduplicar solicitudes concurrentes, o cuando también implementas invalidación o expiración basada en TTL (cubierto más adelante).

Crecimiento de Caché sin Límites

Sin estrategias de desalojo de caché, tu caché crece para siempre:

// Fuga de memoria esperando a ocurrir
const processUserInput = memoize((input) => expensiveOperation(input))

Cada entrada única se agrega a la caché. En una aplicación de larga ejecución, esto genera fugas de memoria.

Soluciones:

  • Establece un tamaño máximo de caché (desalojo LRU)
  • Agrega expiración TTL (tiempo de vida)
  • Delimita las cachés a ciclos de vida de componentes
function memoizeWithLimit(fn, maxSize = 100) {
  const cache = new Map()
  return (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    if (cache.size >= maxSize) {
      const firstKey = cache.keys().next().value
      cache.delete(firstKey)
    }
    const result = fn(...args)
    cache.set(key, result)
    return result
  }
}

Memoización Asíncrona Bien Hecha

La memoización asíncrona necesita manejo especial para llamadas concurrentes y fallos:

function memoizeAsync(fn) {
  const cache = new Map()
  return async (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    
    const promise = fn(...args).catch(err => {
      cache.delete(key) // No almacenes fallos en caché
      throw err
    })
    
    cache.set(key, promise)
    return promise
  }
}

Almacena la promesa en caché inmediatamente. Esto previene solicitudes concurrentes duplicadas. Elimina en caso de fallo para que los reintentos funcionen.

Mejores Prácticas de useMemo en React

useMemo y React.memo son optimizaciones específicas, no valores predeterminados. Agregan complejidad y pueden perjudicar el rendimiento cuando se usan incorrectamente (consulta la documentación oficial de React: https://react.dev/reference/react/useMemo).

Usa useMemo cuando:

  • Calcular datos derivados de props/state sea mediblemente lento
  • Estés pasando objetos a hijos memoizados

Omite useMemo cuando:

  • El cálculo sea trivial
  • No hayas medido un problema de rendimiento
// Probablemente innecesario
const doubled = useMemo(() => value * 2, [value])

// Potencialmente útil
const sortedItems = useMemo(
  () => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
  [items]
)

La comparación de dependencias de React usa Object.is—igualdad por referencia. Los nuevos literales de objeto rompen la memoización en cada renderizado.

El Marco de Decisión

Antes de memoizar, pregunta:

  1. ¿Es la función pura? Sin efectos secundarios, las mismas entradas siempre producen las mismas salidas.
  2. ¿Es realmente lenta? Perfila primero. No adivines.
  3. ¿Son los argumentos primitivos o referencias estables? Los argumentos de objeto necesitan manejo cuidadoso.
  4. ¿Cuál es el tiempo de vida de la caché? Las cachés sin límites generan fugas de memoria.

Conclusión

La memoización intercambia memoria por velocidad. Asegúrate de estar obteniendo un buen trato. Comienza perfilando para confirmar que existe un problema real de rendimiento, luego aplica memoización selectivamente a funciones puras con argumentos predecibles. Implementa límites de caché para prevenir fugas de memoria, maneja operaciones asíncronas cuidadosamente, y recuerda que el useMemo de React es una herramienta de optimización—no un patrón predeterminado. Cuando se hace bien, la memoización elimina cálculos redundantes. Cuando se hace mal, introduce bugs sutiles que perduran más que las ganancias de rendimiento.

Preguntas Frecuentes

Memoizar llamadas a API es arriesgado porque los datos del servidor cambian con el tiempo. Si almacenas la promesa en caché, las llamadas subsiguientes devuelven datos obsoletos. Solo memoiza llamadas a API cuando explícitamente quieras deduplicar solicitudes concurrentes y cuando implementes invalidación de caché o expiración TTL para actualizar datos periódicamente.

Los objetos se comparan por referencia, no por valor. Si mutas un objeto y llamas a la función memoizada nuevamente, devuelve el resultado en caché porque la referencia no ha cambiado. Usa patrones de datos inmutables, crea nuevos objetos en lugar de mutar, o serializa argumentos con JSON.stringify para casos simples.

Perfila antes y después usando las DevTools del navegador o herramientas de perfilado de Node.js. Mide el tiempo de ejecución y el uso de memoria. Si la función se ejecuta con poca frecuencia o calcula rápidamente, la sobrecarga de memoización puede exceder los ahorros. La tasa de aciertos de caché también importa—tasas bajas significan memoria desperdiciada con beneficio mínimo.

No. useMemo agrega sobrecarga para el seguimiento y comparación de dependencias. Para cálculos simples como matemáticas básicas o concatenación de cadenas, el costo de memoización excede el costo del cálculo. Reserva useMemo para operaciones costosas como ordenar arreglos grandes, filtrado complejo, o crear objetos pasados a componentes hijos memoizados.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay