wandres.dev
ASSETS · CSS, imágenes, fuentes

Inlining de assets pequeños: el umbral y su precio

Por debajo de un umbral de tamaño, Vite deja de emitir un asset como archivo propio y lo incrusta como data URI base64 dentro del JS o el CSS que lo referencia. Una petición menos a cambio de un tercio más de bytes y la pérdida de caché independiente: un intercambio deliberado cuyo punto de equilibrio cambió con HTTP/2, y que aprenderás a decidir por archivo.

⏱ 15 min

Por debajo de cierto tamaño, Vite deja de emitir un asset pequeño como archivo propio y lo incrusta como data URI base64 directamente en el JS o el CSS que lo referencia. Una petición de red menos, a cambio de aproximadamente un tercio más de bytes y la renuncia a una caché independiente. Es un intercambio deliberado, y saber dónde cae el punto de equilibrio —que además se movió con la llegada de HTTP/2— es justo el objetivo de esta lección.

🎯 Al terminar esta lección sabrás
  • Entender qué hace build.assetsInlineLimit y cuál es su valor por defecto.
  • Ver cómo un asset se convierte en un data URI base64 y qué crece al incrustarlo.
  • Sopesar el trade-off: una petición menos frente a más bytes y la caché perdida.
  • Controlar el inlining por archivo con la forma de función y los sufijos ?inline y ?no-inline.

El umbral: assetsInlineLimit

La palanca es build.assetsInlineLimit, y su valor por defecto son 4096 bytes, es decir 4 KiB. Todo asset por debajo de ese umbral se incrusta; todo asset por encima recibe su propio archivo con hash. Incrustar significa que los bytes del recurso se codifican en base64 y se empotran, como un data URI, justo donde iría el valor de la importación. No hay una petición de red aparte: el recurso viaja dentro del archivo que lo usa.

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    // Numero: umbral global en bytes (por defecto 4096)
    assetsInlineLimit: 4096,
  },
});

El efecto se ve mejor en la salida. Un icono diminuto desaparece del listado de dist —no genera archivo— y aparece en cambio como una cadena data: dentro del bundle; un asset mayor conserva su archivo con hash.

dist/assets/
├─ index.4f2a9c.js     # aqui dentro viaja el icono de 900 bytes
└─ hero.8b1d3e.avif    # 40 KB: supera el umbral, archivo propio
// Lo que queda incrustado en el bundle para el icono pequeno:
const icono = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...";
Recurso típico Tamaño aproximado Decisión por defecto
icono SVG de interfaz 0.5 KB incrustado
logo pequeño 3 KB incrustado
foto o ilustración 20 KB o más archivo con hash
fuente woff2 15 KB o más archivo con hash

Por qué 4 KiB y no más

El número no es mágico, pero tampoco arbitrario. Cuatro kibibytes es, aproximadamente, el punto donde el coste de una petición HTTP moderna deja de compensar el sobrecoste de base64 más la pérdida de caché independiente. Por debajo, el recurso es tan pequeño que la petición pesa relativamente más que sus bytes; por encima, los bytes dominan y conviene pagar la petición a cambio de un archivo cacheable y compartible. Es un umbral pensado para el transporte de 2026, y por eso conviene bajarlo, no subirlo, si tu perfil de red es bueno.

Qué crece y qué se pierde al incrustar

El primer coste es de bytes. Base64 representa cada 3 bytes de entrada con 4 caracteres de salida, así que infla el tamaño en torno a un tercio: una imagen de 3 KB se convierte en unos 4 KB de texto. El segundo coste es de caché. El data URI vive dentro de un archivo JS o CSS que lleva hash de contenido, de modo que el asset pequeño ya no puede cachearse por su cuenta: comparte la vida de caché de su contenedor, y cambiar el asset cambia el hash del contenedor —con toda la cascada de la lección anterior—. El tercer coste es la duplicación: si dos chunks incrustan el mismo icono, se envía dos veces; si lo referencian como archivo, comparten un único artefacto cacheado.

Los tres costes, resumidos:

  • Más bytes. Base64 infla el recurso en torno a un tercio.
  • Sin caché propia. Queda soldado al hash de su contenedor.
  • Duplicación. Cada chunk que lo incrusta envía su propia copia.

Contra todo eso, la ganancia es una sola: te ahorras un viaje de ida y vuelta HTTP. Bajo HTTP/1.1, con su límite de unas seis conexiones por origen, ese ahorro era enorme y justificaba incrustar con generosidad. Bajo HTTP/2 y HTTP/3, con multiplexación, el coste por petición cayó mucho, y por eso el punto dulce moderno son solo los assets muy pequeños.

Un matiz sobre los bytes: la compresión gzip o brotli recupera parte del sobrecoste de base64, porque el texto codificado comprime bien. Pero no lo recupera del todo, y no toca los otros dos costes —la caché soldada y la duplicación—, que son estructurales y ninguna compresión deshace.

flowchart LR
A[asset pequeno] --> B[por debajo del umbral]
B --> C[base64 incrustado en JS o CSS]
C --> D[una peticion de red menos]
C --> E[mas bytes y sin cache propia]
style C fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111b
⚠️
HTTP/2 cambió la aritmética del inlining

La vieja sabiduría de “incrústalo todo para reducir peticiones” venía de HTTP/1.1, donde cada origen limitaba a unas seis conexiones y cada petición era cara. Con la multiplexación de HTTP/2 y HTTP/3, muchas peticiones pequeñas son baratas, así que incrustar sin criterio puede ser una pérdida neta: pagas los bytes extra de base64, duplicas recursos compartidos y castigas la caché. Por eso el umbral por defecto es deliberadamente pequeño, no generoso: la herramienta ya asume el transporte moderno.

Control fino: función, ?inline y ?no-inline

assetsInlineLimit acepta también una función que decide archivo a archivo. Recibe la ruta y el contenido, y devuelve true para forzar incrustación, false para forzar archivo, o undefined para delegar en el umbral por defecto. Así puedes decir “nunca incrustes nada bajo esta carpeta” o “incrusta solo por debajo de 2 KiB”.

export default defineConfig({
  build: {
    assetsInlineLimit: (filePath, content) => {
      // Nunca incrustar lo que viva bajo assets/keep-as-file
      if (filePath.includes("keep-as-file")) return false;
      // Incrustar solo por debajo de 2 KiB
      return content.length < 2048;
    },
  },
});

Y para casos puntuales tienes los sufijos por importación de la primera lección: ?inline fuerza el data URI aunque el asset supere el umbral, y ?no-inline garantiza un archivo aparte aunque sea diminuto. Son la vía de escape cuando la política global es correcta pero un recurso concreto pide lo contrario.

El SVG merece criterio propio

El SVG es el caso donde la decisión se complica, porque es texto, no binario. Incrustarlo evita la petición y, al ser texto, ni siquiera paga el impuesto de base64 si se codifica como utf8 en lugar de base64. Pero un SVG incrustado no se cachea aparte, no puede referenciarse desde varios sitios sin duplicarse, y engorda el archivo que lo contiene. La regla útil: un icono minúsculo de un solo uso gana incrustado; un logo compartido por muchas vistas, o un SVG que también sirve como imagen enlazada, gana como archivo con ?url.

// Icono unico y diminuto: incrustar es razonable
import check from "./check.svg?inline";

// Logo compartido en muchas vistas: mejor un archivo cacheable
import logoUrl from "./logo.svg?url";

Las fuentes casi nunca se incrustan

Una fuente es el contrapunto perfecto del icono diminuto: es grande, se comparte en toda la app y rara vez cambia. Incrustarla la clavaría al hash de un chunk, duplicándola entre los que la usen y matando la caché de un recurso que el navegador querría guardar meses. Por eso las fuentes deben ser siempre archivos, y por eso quedan muy por encima del umbral por defecto; si alguna cayera por debajo, fuérzala con ?no-inline.

📝
La regla de decisión, en orden de prioridad

Vite decide de más específico a más general: si la importación trae ?inline o ?no-inline, gana ese sufijo; si no, y assetsInlineLimit es una función, manda lo que devuelva salvo que devuelva undefined; y en último término se compara el tamaño contra el número umbral. Conocer ese orden te dice exactamente qué palanca tocar para forzar un caso concreto sin alterar la política global.

Incrustar tiene sentido

Assets diminutos, estables y de un solo uso: un gradiente de 200 bytes, una máscara de 1 KB, un placeholder borroso.

🚫

Mejor como archivo

Todo lo grande, compartido o volátil: así la caché direccionada por contenido puede cachearlo y compartirlo.

En la práctica, el ajuste que más rinde no es tocar el número sino respetar la intención: deja el umbral pequeño, incrusta lo diminuto de un solo uso y saca a archivo todo lo demás. La política por defecto acierta en la mayoría de los casos; tu trabajo es reconocer los pocos que piden lo contrario.

💡
Mide, no supongas

La pestaña de red y el informe de tamaño del build te dicen la verdad que ninguna regla general alcanza: cuántas peticiones ahorras, cuántos bytes añades y qué se duplica entre chunks. Antes de mover el umbral, mira esos números en tu proyecto real; el punto de equilibrio depende de tu red, tu CDN y tu patrón de navegación, no de una cifra universal.

El umbral es una apuesta sobre la segunda visita y sobre compartir

El umbral es la respuesta heurística a una pregunta de optimización real que no tiene solución universal: ¿sale más barato pagar una petición ahora, o cargar estos bytes dentro de otro archivo para siempre? El análisis honesto tiene tres ejes. El primero es el transporte: un archivo aparte cuesta una petición —establecerla, sus cabeceras, un viaje de ida y vuelta—; incrustar cuesta un tercio más de bytes y la pérdida de compresión independiente entre archivos. Bajo HTTP/1.1 el villano era la petición; bajo HTTP/2 y HTTP/3 la petición es barata y los bytes extra pasan a ser el villano, y por eso el umbral por defecto es minúsculo y no generoso. El segundo eje es la caché: un archivo con hash se cachea para siempre y de forma independiente; un asset incrustado queda soldado al hash de su contenedor, así que no puede reutilizarse entre páginas que no cargan ese contenedor, y cualquier cambio suyo invalida el contenedor entero. El tercer eje es la duplicación: dos chunks que incrustan el mismo icono lo envían dos veces, mientras que dos chunks que lo referencian comparten un único artefacto cacheado. Puestos juntos, la regla que emerge es afilada: incrusta solo assets que sean diminutos, poco propensos a cambiar y usados en un único lugar. Todo lo mayor, compartido o volátil quiere ser un archivo, porque entonces el almacenamiento direccionado por contenido de la lección anterior puede hacer su trabajo. El umbral es donde trazas esa línea, y por defecto la traza conservadora a propósito. La meta-lección, la que de verdad se te queda, es que “menos peticiones” no es un objetivo sino un indicador indirecto que era certero en un transporte y engañoso en otro: optimiza la cosa misma —bytes totales a lo largo de la sesión, reutilización de caché durante la visita—, no el indicador. Casi todas las malas decisiones de rendimiento nacen de seguir puliendo un proxy cuya correlación con lo que importa ya se rompió.

⚔️ Encuentra tu punto de equilibrio
  1. Añade una imagen de 1 KB y otra de 10 KB, construye y confirma que la pequeña quedó como data URI y la grande como archivo con hash.
  2. Baja assetsInlineLimit a 0, reconstruye y confirma que todos los assets pasan a ser archivos; cuenta las peticiones extra en la pestaña de red.
  3. Incrusta un asset y mide el aumento de tamaño frente a sus bytes originales: verás en torno a un tercio más.
  4. Importa el mismo icono pequeño en dos chunks de ruta distintos y comprueba que se duplica al incrustarse y se comparte al forzarlo a archivo con ?no-inline.
  5. Escribe la forma de función para incrustar solo bajo una carpeta concreta y verifica que el resto se quedan como archivos.