wandres.dev
FUENTES Y RENDIMIENTO · El texto que aparece rápido

La cadena de carga de una fuente, del CSS al primer glifo

Los ocho pasos que separan el byte inicial del HTML del primer carácter pintado con tu tipografía, por qué el navegador no pide la fuente hasta muy tarde, y dónde se puede cortar la cadena.

⏱ 18 min

Una fuente es el recurso con el descubrimiento más tardío de toda la página. No está en el HTML, así que el escáner de precarga no la ve; está en el CSS, pero declararla no la pide; se pide solo cuando el navegador ha construido el árbol de renderizado y ha comprobado que hay texto real que la necesita. Esa cadena de dependencias explica por qué el texto tarda tanto en aparecer con su forma definitiva, y entenderla eslabón a eslabón es lo que permite decidir cuál cortar.

🎯 Al terminar esta lección sabrás
  • Enumerar los ocho pasos de la cadena desde el HTML hasta el primer glifo.
  • Explicar por qué declarar una regla de fuente no provoca ninguna descarga.
  • Cuantificar el coste de cada eslabón en una conexión móvil realista.
  • Identificar los tres puntos donde la cadena se puede acortar.

Los ocho pasos

flowchart TB
A[El navegador recibe el HTML] --> B[Encuentra el enlace a la hoja de estilos]
B --> C[Descarga la hoja y construye el CSSOM]
C --> D[Registra las reglas font-face sin pedir ningun fichero]
D --> E[Construye el arbol de renderizado]
E --> F[Un nodo de texto necesita una familia concreta]
F --> G[Emparejamiento por familia peso estilo y unicode-range]
G --> H[Ahora si peticion del fichero de fuente]
H --> I[Descarga y descompresion del WOFF2]
I --> J[Saneado y validacion de las tablas]
J --> K[Modelado del texto y rasterizado de glifos]
K --> L[Primer glifo pintado con la fuente real]
style C fill:#89b4fa,color:#11111b
style D fill:#cba6f7,color:#11111b
style F fill:#cba6f7,color:#11111b
style H fill:#f9e2af,color:#11111b
style I fill:#f9e2af,color:#11111b
style L fill:#a6e3a1,color:#11111b

Los dos nodos morados son los que sorprenden a todo el mundo, y merecen su propia explicación.

Registrar no es descargar. Cuando el navegador parsea una regla @font-face, lo único que hace es anotar en una tabla que existe una cara de fuente con ese nombre, ese peso, ese estilo, ese rango de caracteres y esa URL. No emite ninguna petición. La razón de diseño es evidente en cuanto se piensa: una hoja de estilos de un sistema de diseño puede declarar cuarenta caras, y una página concreta usa dos. Descargarlas todas al parsear el CSS sería absurdo.

La petición nace del uso. El disparador es que un elemento del árbol de renderizado, con texto real dentro, resuelva su font-family a esa cara. Eso significa que si tienes una regla para una familia que ninguna regla aplica a ningún elemento, el fichero no se descarga jamás. Y significa también algo menos cómodo: la petición no puede empezar hasta que el árbol de renderizado existe, y el árbol de renderizado no existe hasta que el CSS entero está descargado y parseado, porque el CSS bloquea el renderizado.

De ahí sale la propiedad más importante de la cadena: entre el primer byte del HTML y la petición de la fuente hay como mínimo un viaje completo de ida y vuelta adicional, el de la hoja de estilos. Con el CSS en un fichero externo del mismo origen, eso es un viaje. Con el CSS que a su vez importa otra hoja, dos. Con la fuente en un origen distinto, hay que sumar además la resolución de nombres, la conexión y el apretón de manos de cifrado con ese origen.

El emparejamiento, que es más selectivo de lo que parece

Cuando un nodo necesita una familia, el navegador aplica el algoritmo de emparejamiento de fuentes, y lo hace en un orden concreto que tiene consecuencias prácticas:

Primero la familia. Se recorre la lista de font-family de izquierda a derecha.

Después el rango de caracteres. Dentro de una familia, solo se consideran las caras cuyo unicode-range contenga alguno de los caracteres que hay que pintar. Una cara cuyo rango no toca ningún carácter de la página no se descarga nunca, y ese es todo el mecanismo de los subconjuntos.

Después el estirado, el estilo y el peso, en ese orden y con reglas de aproximación bien definidas cuando no hay coincidencia exacta.

La consecuencia práctica más útil: el número de ficheros que se descargan no depende de cuántas caras declares, sino de cuántas combinaciones distintas de familia, rango y peso aparecen efectivamente en el texto renderizado. Un párrafo en cursiva negrita perdido en el pie de página descarga un fichero entero.

Cuánto cuesta cada eslabón

Las cifras que siguen son de una conexión móvil de tipo 4G con unos 100 milisegundos de ida y vuelta, que es un escenario intermedio razonable. En una conexión lenta de verdad hay que multiplicar por tres o cuatro.

Eslabón Coste típico
HTML hasta el primer byte 150-400 ms
Descubrir y descargar el CSS 150-350 ms
Construir el CSSOM y el árbol de renderizado 10-60 ms
Conexión a un origen de fuentes distinto 200-500 ms
Descargar un subconjunto latino de 25 KB 120-250 ms
Descomprimir el WOFF2 1-5 ms
Sanear y validar las tablas 1-10 ms
Modelar y rasterizar los primeros glifos 2-15 ms

Dos lecturas de la tabla.

La primera: todo el coste está en la red. La suma de descompresión, validación y rasterizado no llega a 30 milisegundos ni en el peor caso. Optimizar el fichero para que se parsee más rápido no existe como problema; optimizarlo para que pese menos y llegue antes, sí.

La segunda: la cadena es serial. Los eslabones no se solapan, porque cada uno depende del anterior. Sumados dan entre 450 y 1.100 milisegundos hasta el primer glifo con la fuente real, en el caso bueno de origen propio, y entre 650 y 1.600 si la fuente vive en otro dominio. Esa es la ventana durante la cual tu texto se ve con otra tipografía o no se ve en absoluto.

La medición en tu propio sitio no necesita ninguna herramienta:

const fuentes = performance.getEntriesByType('resource')
  .filter((r) => /\.(woff2?|ttf|otf)(\?|$)/.test(r.name))
  .map((r) => ({
    fichero: r.name.split('/').pop().slice(0, 32),
    inicio: Math.round(r.startTime),
    espera: Math.round(r.responseStart - r.startTime),
    descarga: Math.round(r.responseEnd - r.responseStart),
    kb: Math.round(r.encodedBodySize / 1024),
  }));

console.table(fuentes);
console.log('primer glifo listo hacia', Math.round(Math.max(...fuentes.map((f) => f.inicio + f.espera + f.descarga))), 'ms');

La columna inicio es la que responde la pregunta que importa: cuándo empezó la petición. Si sale por encima de los 400 o 500 milisegundos, la cadena está intacta y hay margen para cortarla. Si sale cerca de los 100, alguien ya la ha cortado.

El proveedor de fuentes gratuito añade dos eslabones a la cadena, y uno de ellos no se puede precargar

Cargar una fuente desde un servicio de terceros con dos líneas pegadas del panel del proveedor es cómodo, y produce la peor cadena de carga posible. Vale la pena desmontarla eslabón a eslabón, porque el daño no está donde la gente cree.

La estructura habitual son dos orígenes distintos: uno que sirve una hoja de estilos con las reglas @font-face, y otro que sirve los ficheros de fuente. Y la cadena queda así:

  1. El HTML se parsea y encuentra el enlace a la hoja del proveedor.
  2. Hay que resolver el nombre del primer origen, conectar y negociar el cifrado. Uno a tres viajes.
  3. Se descarga esa hoja, que además es bloqueante del renderizado, porque es una hoja de estilos.
  4. Se parsea, y ahora se conocen las reglas, que apuntan a un segundo origen.
  5. Se construye el árbol de renderizado y aparece la necesidad de la fuente.
  6. Hay que resolver el nombre del segundo origen, conectar y negociar el cifrado otra vez. Uno a tres viajes más.
  7. Y ahora, por fin, se descarga el fichero.

Son dos negociaciones de conexión completas encadenadas más una hoja de estilos bloqueante en medio. En una conexión móvil eso son entre 400 y 900 milisegundos que no existen si la fuente vive en tu propio origen.

Y aquí está el detalle que casi nadie conoce y que hace que la mitigación habitual no funcione del todo: no puedes precargar el fichero de fuente, porque no sabes su URL. El proveedor la genera dinámicamente según el navegador que pregunta, precisamente para servir el formato y el subconjunto adecuados, y esa URL cambia entre versiones. Lo único que puedes precargar es la hoja de estilos, que es el eslabón barato.

La mitigación parcial y correcta, si de verdad no puedes autoalojar, es abrir las dos conexiones por adelantado:

<link rel="preconnect" href="https://origen-de-la-hoja.example">
<link rel="preconnect" href="https://origen-de-los-ficheros.example" crossorigin>

El crossorigin en el segundo no es decorativo: las fuentes se piden siempre en modo de origen cruzado anónimo, y sin ese atributo el preconnect abre una conexión con credenciales que no se reutiliza para la petición de la fuente. Se abre una segunda conexión y no has ganado nada. Es el error más común de esta técnica y es completamente silencioso.

Con los dos preconnect bien puestos se recuperan entre 100 y 300 milisegundos. Autoalojando se recuperan todos, se elimina la hoja bloqueante de terceros, y además se gana la capacidad de precargar el fichero concreto porque su URL la controlas tú. El proceso es de una tarde: descargar los ficheros, generar el subconjunto que necesitas, escribir las reglas a mano, y servirlo con caché inmutable.

Un último argumento que suele cerrar la discusión con quien defiende el proveedor por la caché compartida: esa caché compartida ya no existe. Los navegadores particionan la caché HTTP por sitio desde hace años, precisamente para evitar el rastreo entre sitios. La fuente que un usuario descargó en otra web no se reutiliza en la tuya. El argumento era cierto en 2015 y hoy no lo es.

Los tres cortes

De la estructura de la cadena salen exactamente tres intervenciones, y las tres atacan un eslabón distinto.

Cortar la espera del CSS: precargar. Una directiva de precarga en el HTML la ve el escáner de precarga mientras parsea, es decir, antes de que exista el CSSOM. La petición de la fuente arranca en paralelo con la del CSS en lugar de después. Ahorra el viaje completo de la hoja de estilos, que en móvil son de 150 a 350 milisegundos.

Cortar la conexión extra: autoalojar. Sirve la fuente desde tu propio origen y desaparecen la resolución de nombres, la conexión y el cifrado del origen ajeno, además de la hoja bloqueante intermedia.

Cortar la descarga: reducir el fichero. Un subconjunto latino de 20 KB tarda la mitad que una fuente completa de 60. Y hay un caso extremo que merece conocerse: para textos muy cortos y conocidos, como un logotipo tipográfico o un titular fijo, un subconjunto de los caracteres exactos puede bajar de 4 KB, que en la práctica es un solo paquete.

Ninguna de las tres cambia lo que ocurre mientras la fuente no está: eso es responsabilidad de font-display, y es lo siguiente.

⚔️ Reto práctico

Ejecuta el bloque de medición en tu página y anota inicio y la suma total para cada fichero de fuente. Después repite la medición con la red estrangulada a una conexión lenta. La diferencia entre las dos sumas es lo que tus usuarios de conexión mala están esperando para ver el texto con su forma definitiva, y es la cifra que justifica todo lo que viene después.