Miniaturas WebP con sharp: caché LRU y el coste de la BD
Guardar imágenes en la BD simplifica la operación si el JSON no las arrastra: variantes WebP bajo demanda con caché LRU. Diseño con sharp, límites y costes.
PorSergio Jáuregui
9 min de lectura
En este artículo
- Base64 fuera del JSON: el listado no debería pagar por imágenes
- Variantes bajo demanda: miniaturas WebP con sharp
- Caché LRU en memoria: cómo funciona y qué no hace
- El coste real de guardar imágenes en la base de datos
- Cuándo migrar a almacenamiento de objetos o CDN
- Cómo medir el coste de tus imágenes sin autoengañarte
- Saca las imágenes del JSON esta semana
Las miniaturas WebP con sharp permiten guardar las imágenes originales en la base de datos sin pagarlas en cada respuesta JSON: se sirven variantes pequeñas bajo demanda, con una caché LRU en memoria. El JSON viaja ligero y la imagen pesada llega por su propia URL cacheable. Es una decisión con coste explícito, no una moda.
Esta guía cubre el diseño completo: qué sacar del JSON, cómo construir variantes a partir de parámetros, qué límites tiene la caché en memoria, cuánto cuesta guardar imágenes en la base de datos y cuándo conviene migrar a almacenamiento de objetos. Los números que aparecen son valores de una implementación de referencia, no una regla universal. Una nota honesta: no hay cifras de rendimiento generales; al final explico cómo medir las tuyas.
Base64 fuera del JSON: el listado no debería pagar por imágenes
Una imagen guardada como data URL en base64 ocupa cerca de un tercio más que el binario original y viaja con cada respuesta que la incluya. Si un listado incluye esas cadenas, cada visita paga el peso de todas las imágenes aunque nadie abra ninguna. La solución habitual es separar: el JSON lleva una URL de miniatura pequeña, y la imagen completa se pide aparte, solo cuando hace falta.
Esa separación suele activarse con un parámetro explícito, por ejemplo ?images=url. Sin el parámetro, el contrato antiguo sigue funcionando; con él, la respuesta trae una thumbnailUrl en lugar del data URL. Ahí está la trampa: un cliente antiguo seguirá recibiendo base64 mientras no pida el parámetro. Conviene migrar a todos los clientes y retirar el modo antiguo cuando ya no quede ninguno.
Lo mismo aplica a los paneles de administración. El listado de medios debe excluir el campo pesado y cargarlo solo en la ficha de detalle, para no arrastrarlo en cada consulta.
Variantes bajo demanda: miniaturas WebP con sharp
Cada miniatura se sirve desde una ruta del tipo /:slug/thumbnail con dos parámetros. w pide un ancho, y v lleva una marca de versión, normalmente la fecha de la última edición. Hay dos decisiones que evitan problemas.
La primera es redondear el ancho a cubos fijos. Si cada píxel pedido generara una variante distinta, la caché se llenaría de copias casi idénticas. Una implementación de referencia usa seis anchos y redondea hacia arriba al más cercano:
// Implementación de referencia: anchos fijos y redondeo hacia arriba
export const THUMBNAIL_WIDTHS = [320, 480, 640, 960, 1280, 1920] as const;
export function pickThumbnailWidth(requested?: number): number {
if (!requested || !Number.isFinite(requested) || requested <= 0) return 640;
return THUMBNAIL_WIDTHS.find((w) => w >= requested) ?? THUMBNAIL_WIDTHS[THUMBNAIL_WIDTHS.length - 1];
}
La segunda es que v cambie con cada edición. La URL nueva invalida la caché del navegador y de cualquier intermediario sin tocar el servidor, así que la respuesta puede marcarse como inmutable durante un año. Sin v, basta una caché corta de unos minutos:
// Implementación de referencia: con versión, caché inmutable; sin ella, caché corta
const version = typeof req.query.v === 'string' ? req.query.v : undefined;
res.set({
'Content-Type': 'image/webp',
'Cache-Control': version ? 'public, max-age=31536000, immutable' : 'public, max-age=300',
});
La generación corre sobre sharp en tres pasos: rotar según los metadatos, redimensionar sin ampliar y convertir a WebP. Como referencia, el Portfolio de sergioja.com sirve así sus miniaturas, y en el frontend el navegador elige el ancho con srcset, de modo que cada pantalla descarga la variante que necesita.
import sharp from 'sharp';
// Implementación de referencia: rotar, redimensionar sin ampliar y convertir a WebP
export async function makeThumbnail(input: Buffer, width: number): Promise<Buffer> {
return sharp(input, { limitInputPixels: 100_000_000 })
.rotate()
.resize({ width, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
}
Caché LRU en memoria: cómo funciona y qué no hace
Generar una variante cuesta CPU, así que las ya generadas se guardan en memoria. Una caché LRU (least recently used) expulsa primero lo que lleva más tiempo sin usarse. Con un Map de JavaScript basta: su orden de inserción actúa como cola de antigüedad. Cada acierto mueve la entrada al final, y al superar el tope se expulsa desde el principio.
// Implementación de referencia: caché LRU con Map (el orden de inserción marca la antigüedad)
const MAX_BYTES = 64 * 1024 * 1024;
const cache = new Map<string, Buffer>();
let cacheBytes = 0;
export function getVariant(key: string): Buffer | undefined {
const hit = cache.get(key);
if (hit) {
cache.delete(key); // al final: lo más reciente
cache.set(key, hit);
}
return hit;
}
export function putVariant(key: string, buffer: Buffer): void {
if (buffer.length > MAX_BYTES) return;
cache.set(key, buffer);
cacheBytes += buffer.length;
for (const [k, v] of cache) {
if (cacheBytes <= MAX_BYTES) break;
cache.delete(k); // desde el principio: lo más antiguo
cacheBytes -= v.length;
}
}
En la implementación de referencia el tope es de 64 MB, y una variante mayor que ese tope nunca se guarda. La generación se estima en 50–150 ms de CPU, con la concurrencia de sharp limitada a 2. Un mapa de promesas en vuelo evita generar dos veces la misma variante si dos peticiones llegan a la vez: un detalle que se olvida y que multiplica el trabajo bajo carga.
Tres límites conviene tener claros. La caché vive en el proceso: cada instancia tiene la suya, y un arranque en frío la encuentra vacía. No sobrevive a un reinicio ni se comparte entre réplicas. Y bajo presión de memoria las variantes viejas se regeneran bajo demanda, así que el coste de un desbordamiento es CPU, no un error para quien mira.
El coste real de guardar imágenes en la base de datos
Guardar imágenes en la base de datos simplifica la operación: hay una sola copia que respaldar y ningún bucket que sincronizar. El coste es que cada imagen suma bytes a cada copia de seguridad y que leerla es una consulta más. Las variantes con caché mitigan lo segundo; lo primero se acepta mientras el volumen sea manejable.
La ingesta también merece control. Un patrón sensato combina un tope de tamaño por subida, un ancho máximo que no amplíe las imágenes pequeñas, salida WebP con calidad fija y un tope de píxeles de entrada para frenar las bombas de píxeles, imágenes diminutas que se descomprimen a dimensiones enormes. El formato debe detectarse por el contenido del fichero, no por el tipo MIME declarado, y solo deben aceptarse los formatos de mapa de bits que necesitas. Un SVG o un fichero corrupto deben rechazarse con un error propio.
Una nota de criterio: las copias de seguridad crecen con las imágenes, y restaurar una base de datos con gigas de binarios tarda más. Conviene vigilar ese tamaño desde el principio, no cuando la restauración ya duele.
Cuándo migrar a almacenamiento de objetos o CDN
Este diseño deja de compensar en cuatro situaciones. Primera, cuando las copias de la base de datos pesan tanto que moverlas o restaurarlas se vuelve una tarea temida. Segunda, cuando hay varias réplicas y cada caché en memoria duplica trabajo sin compartir nada. Tercera, cuando el tráfico convierte la regeneración en CPU constante en lugar de picos ocasionales. Cuarta, cuando necesitas variantes en el borde de la red, formatos nuevos o activos que no son imágenes, como vídeo.
No hay un umbral universal, porque depende de la máquina, del tráfico y de la tolerancia a copias pesadas. Lo que conviene vigilar es el tamaño de las tablas de medios, la duración de las copias de seguridad y la frecuencia de regeneración. Mientras esas tres curvas sean planas, la base de datos sigue siendo un almacén razonable. Cuando dejen de serlo, migrar consiste en cambiar el origen de los bytes, no el diseño de variantes y caché, que funciona igual sobre un bucket.
No lo haría tampoco para contenido efímero ni para subidas de terceros sin control. Cada subida suma bytes permanentes, y ninguna validación distingue una imagen útil de una prueba olvidada.
Cómo medir el coste de tus imágenes sin autoengañarte
Esta guía no trae benchmarks propios: los valores de arriba son los de la implementación de referencia, y tu sistema tendrá los suyos. Lo que sí puedes hacer es medir con un procedimiento sencillo, antes de afirmar nada.
Primero, compara el tamaño de la respuesta JSON con y sin el parámetro de URL: esa diferencia es lo que pagaba cada listado. Segundo, cronometra la primera petición de una variante, que genera, frente a la segunda, que sale de memoria. Tercero, mide cuánto ocupan las tablas de imágenes y cuánto aportan a cada copia de seguridad. Cuarto, revisa los logs: los fallos de generación deben dejar aviso, y un sistema sano muestra pocos.
Con esas cuatro medidas sabes si hace falta optimizar más las imágenes o si el cuello de botella está en otro sitio. Medir antes de tocar evita el error clásico: rediseñar el sistema de imágenes cuando el problema era un listado sin paginar.
Saca las imágenes del JSON esta semana
El trabajo se divide en pasos pequeños. Guarda el original donde quieras, sirve variantes pequeñas por URL con ancho y versión, marca como inmutables las que cambian de URL al editar y mide el JSON antes y después. Cada paso funciona por separado; juntos convierten las imágenes en un coste controlado.
Este patrón se combina con la caché del frontend. En la guía sobre Next.js revalidateTag se explica cómo invalidar solo lo que cambia en el caché ISR, y en la de sitio web 3D SEO, el otro frente pesado: el bundle 3D. Si quieres ver un proyecto real con este enfoque, consulta la implementación de referencia, y si quieres este diseño en tu web, escríbeme desde la página de contacto.
¿Tienes un proyecto en mente?
Soy Sergio Jáuregui, desarrollador Full Stack. Si te ha gustado este artículo, mira mi trabajo o escríbeme y lo hablamos.
Seguir leyendo
Ingeniería y rendimientoGuía
Sitio web 3D SEO: haz que Google lea tu web con Three.js
El canvas es invisible para Google: lo que posiciona es el HTML que acompaña a tu 3D. Cómo construirlo, cargar el 3D y auditarlo en cuatro pruebas.
Ingeniería y rendimientoGuía
Next.js revalidateTag: invalida solo lo que cambia en tu caché ISR
El revalidate por tiempo es un suelo, no una estrategia: con etiquetas y revalidación bajo demanda actualizas solo lo que cambió. Flujo, código de referencia y trampas.
Actualizado el 15 oct 2026
Volver arriba