learning-front

Nivel 8 · Calidad: que no se rompa en producción

Performance web y Core Web Vitals

Lazy loading, code splitting y memoización para una web que vuela, con la diferencia entre medir en laboratorio (synthetic, Lighthouse) y con usuarios reales (RUM, datos de campo).

Tu Team Builder funciona. Pasa los tests, hace sus fetch, gestiona su estado. Pero “funciona en mi máquina” no es lo mismo que “va bien para quien lo usa”: tú lo abres en un equipo potente, con fibra y sin nada más cargado; un usuario lo abre en un móvil de gama media, con cobertura regular y quince pestañas abiertas. Igual que un test te avisa de que algo se rompió, una métrica de rendimiento te avisa de que algo se ralentizó. Y en una app de empresa —banca, una tienda— cada segundo de más se traduce en gente que se va. Este capítulo va de eso: medir el rendimiento en serio y, solo entonces, tocar lo que de verdad lo mejora.

El rendimiento es un número: los Core Web Vitals#

Durante años, “esta web va lenta” fue una sensación. Google estandarizó esa sensación en tres números medibles, los Core Web Vitals, que resumen las tres formas en que una página puede sentirse mal:

  • LCP (Largest Contentful Paint) — la carga. Mide cuánto tarda en pintarse el elemento grande que el usuario espera ver: la imagen de cabecera, el titular, el bloque principal. Es la respuesta a “¿ya está esto?”. Objetivo: por debajo de 2,5 s. El “¿y qué?”: si tarda, el usuario mira una pantalla a medio cargar y muchos se van antes de que termine.
  • CLS (Cumulative Layout Shift) — la estabilidad. Mide cuánto se mueve el contenido solo, sin que el usuario lo provoque: ese salto en el que vas a pulsar un botón y, justo al cargar una imagen, todo baja de golpe y pulsas otra cosa. Objetivo: por debajo de 0,1. El “¿y qué?”: no es estética, es que el usuario acaba haciendo clic donde no quería.
  • INP (Interaction to Next Paint) — la respuesta. Mide lo que tarda la interfaz en reaccionar a una interacción: pulsas, escribes, abres un menú, ¿y cuánto pasa hasta que la pantalla se actualiza? Objetivo: por debajo de 200 ms. Es la métrica de “¿por qué va a tirones?”. INP reemplazó a FID como métrica oficial en 2024, porque mide toda la interacción, no solo el primer retardo.

Cada uno mide una cosa distinta, así que una página puede ir bien en uno y fatal en otro: carga rápido (LCP bueno) pero se arrastra al usarla (INP malo). Por eso no hay “una nota de rendimiento”: hay tres, y se miran por separado.

Laboratorio y campo: las dos formas de medir#

Aquí está la distinción que más gente confunde, y es la columna de todo el capítulo. Hay dos sitios donde medir esos números, y dicen cosas distintas.

Laboratorio (synthetic): mides tú, en un entorno controlado. La herramienta estándar es Lighthouse (viene en las DevTools de Chrome): carga tu página en condiciones fijas y reproducibles —una red y una CPU simuladas— y te da un informe. Sirve para depurar y para comparar un antes y un después antes de publicar. Un informe de laboratorio se lee así:

text
Lighthouse (laboratorio · CPU y red simuladas)
  LCP   1,2 s   bueno
  CLS   0,02    bueno
  TBT   40 ms   bueno   ← proxy de INP: en lab no hay interacción real que medir

Fíjate en el detalle honesto: INP casi no se mide en laboratorio, porque necesita interacciones reales de una persona. Lighthouse te da en su lugar el TBT (Total Blocking Time) como aproximación. Esto ya te dice que el laboratorio tiene un techo: no es tu usuario.

Campo (field): mides a tus usuarios reales, en sus móviles y sus redes, mientras usan la app. Esto se llama RUM (Real User Monitoring), y es la única verdad sobre la experiencia. Se consigue de dos formas: instrumentando tú la app (la librería web-vitals de Google mide cada métrica como la vive cada usuario y tú la mandas a tu analítica), o consultando CrUX, los datos de campo que Google recoge de usuarios reales de Chrome.

typescript
// En producción, mides el CAMPO instrumentando la app: web-vitals te entrega cada métrica
// tal y como la vive el usuario real, y tú la envías a tu sistema de analítica (tu RUM).
import { onLCP, onCLS, onINP } from "web-vitals";

// Cada callback se dispara con el valor REAL medido en ESE navegador, no en tu máquina.
onLCP((metrica) => enviarAAnalitica("LCP", metrica.value));
onCLS((metrica) => enviarAAnalitica("CLS", metrica.value));
onINP((metrica) => enviarAAnalitica("INP", metrica.value));

¿Por qué hacen falta los dos? Porque tu Lighthouse puede dar 98/100 mientras el campo muestra un LCP malo: tú mides en un equipo rápido, tus usuarios no. Cuando lab y campo discrepan, manda el campo (es la experiencia real); el laboratorio te sirve para reproducir ese problema y arreglarlo sin tener que esperar a los datos de usuarios. Mídelo en campo para saber qué duele; reprodúcelo en lab para arreglarlo.

En la Demo 1 de abajo tienes un campo en miniatura: dos botones que miden, con performance.now(), cuántos milisegundos bloquea tu clic el hilo principal. Eso —el retardo entre tu interacción y la respuesta— es exactamente lo que el INP mide en tus usuarios.

Las palancas: qué tocas y qué métrica mueve#

Solo después de medir tiene sentido optimizar. Y cada técnica de rendimiento es una palanca atada a una métrica concreta: no se usan todas a la vez “por si acaso”, se usa la que mueve el número que está en rojo.

Cortar re-renders en balde → INP#

El INP malo en una app de React suele venir de lo mismo: el navegador está ocupado re-renderizando de más y no puede atender al usuario. Ya tienes las herramientas del Nivel 7 (memo, useMemo, useCallback); lo nuevo aquí es medir ese desperdicio para saber que existe antes de tocarlo. El medidor de andar por casa es un contador de renders; el de verdad es el Profiler de las React DevTools, que graba una interacción y te pinta qué componente se renderizó y cuántos milisegundos costó.

En la Demo 2 lo ves: el mismo componente con y sin memo, y cuántas veces se renderiza cada uno cuando el padre cambia. El patrón para cortar el desperdicio, como recordatorio del Nivel 7:

tsx
// La tarjeta cara, envuelta en memo: se salta el render si sus props no cambian.
const TarjetaHeroe = memo(function TarjetaHeroe({ heroe, onAñadir }: Props) {
  return <article>{heroe.nombre}</article>;
});

// La función estable (misma referencia entre renders): sin esto, memo no serviría,
// porque la tarjeta vería una prop "nueva" en cada tecla del filtro.
const añadir = useCallback((heroe: Heroe) => {
  setEquipo((e) => [...e, heroe]);
}, []);

Cargar menos de entrada: code splitting y lazy → arranque (LCP)#

El otro gran freno es el tamaño de lo que cargas de inicio. Si tu bundle inicial trae el editor de texto enriquecido, el visor de gráficos y el mapa —que casi nadie abre—, todos tus usuarios pagan ese JavaScript en el arranque. El code splitting parte el bundle en trozos (chunks) que se cargan cuando hacen falta, no antes. En React se hace con lazy y un import() dinámico (el mismo que viste con Vite en el Nivel 4), apoyado en el Suspense del Nivel 7:

tsx
import { lazy, Suspense } from "react";

// El editor pesado NO entra en el bundle inicial: import() crea un chunk aparte que el
// navegador solo descarga cuando este componente se va a renderizar por primera vez.
const EditorRico = lazy(() => import("./EditorRico"));

function PanelNotas({ abierto }: { abierto: boolean }) {
  return (
    // Mientras el chunk del editor llega, Suspense muestra el fallback (Nivel 7).
    <Suspense fallback={<p>Cargando editor…</p>}>
      {abierto && <EditorRico />}
    </Suspense>
  );
}

La misma idea, para imágenes, la trae el propio HTML con el atributo loading="lazy": la imagen no se descarga hasta que está a punto de entrar en pantalla.

html
<!-- La imagen solo se descarga cuando el usuario se acerca a ella al hacer scroll. -->
<img src="/heroes/tracer.webp" alt="Tracer" width="320" height="180" loading="lazy" />

Reservar el espacio → CLS#

El CLS casi siempre tiene la misma raíz: un elemento llega sin que se le hubiera reservado sitio, y empuja lo de abajo. La cura es decirle al navegador, desde el principio, cuánto va a ocupar.

tsx
// MAL: sin dimensiones, el navegador no sabe cuánto ocupará la imagen hasta descargarla,
// y cuando llega, empuja el contenido de abajo. Eso es un salto de layout (sube el CLS).
<img src={heroe.avatar} alt={heroe.nombre} />

// BIEN: con width/height el hueco se reserva desde el primer pintado; la imagen llega
// y rellena su sitio sin mover nada de alrededor.
<img src={heroe.avatar} alt={heroe.nombre} width={320} height={180} />

Fíjate en que el mismo width/height de la imagen de arriba servía para dos cosas: que se cargue tarde (loading="lazy") y que no provoque un salto. Una palanca bien puesta suele tocar más de una métrica.

Mide antes de tocar#

Todo lo anterior se resume en una disciplina, y es lo único que de verdad tienes que llevarte: no optimices a ojo. El orden es siempre el mismo:

  1. Mide. El campo (RUM) te dice qué métrica está mal de verdad para tus usuarios; el laboratorio (Lighthouse, el Profiler) te deja reproducir ese problema y verlo de cerca.
  2. Prioriza por impacto. Ataca la métrica que está en rojo. Bajar un número que ya está en verde no mejora la experiencia de nadie y te quema el tiempo.
  3. Aplica la palanca de esa métrica. Memoización para el INP, code splitting para el arranque, reservar espacio para el CLS. No todas a la vez.
  4. Vuelve a medir. Para confirmar que mejoró —y que no rompiste otra cosa.

Memoizar y partir el bundle “por todas partes por si acaso” antes de medir es el error clásico: añade complejidad y memoria sin saber si arregla nada, y a veces empeora. La memoización mal hecha (una lista de dependencias incompleta) hasta puede mostrar datos viejos en silencio. Medir primero es lo que separa optimizar de adivinar.

Pruébalo#

Demo 1: mide cuánto tarda tu clic (INP). Pulsa “Sumar (rápido)”: responde al instante y la consola marca un tiempo de microsegundos. Ahora pulsa “Sumar + trabajo pesado”: la interfaz se congela unos cientos de milisegundos (según tu equipo) mientras el bucle bloquea el hilo, y la consola lo mide. Ese retardo entre tu clic y la respuesta es justo lo que el INP mide en tus usuarios. La palanca: no hagas trabajo pesado en el hilo principal mientras el usuario interactúa.

Demo 2: mide los re-renders en balde (memoización). Pulsa “Forzar re-render del padre” —que es como cada tecla en un filtro real— y mira los dos contadores: las tarjetas sin memo suben su número cada vez (renders en balde, porque su contenido no cambió); las que usan memo se quedan quietas, porque sus props no cambian de referencia. En una lista de tres no se nota; en una de doscientas, esos renders de más son tu INP cayendo.

Comprueba lo que sabes#

Pregunta 1 de 9

Los usuarios se quejan de que la app "va a tirones" cuando pulsan botones o abren menús, aunque carga rápido. ¿Qué Core Web Vital mide ese problema?

Tu turno#

El roster del Team Builder re-renderiza todas sus tarjetas en cada tecla del filtro. Tienes el medidor delante para verlo: arréglalo memoizando solo donde el medidor acuse, no por costumbre. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un tier al siguiente.

Ejercicio · en esta página

Corta los re-renders en balde del roster

El roster del Team Builder filtra héroes por nombre, y cada tarjeta lleva un medidor de renders (tu Profiler en miniatura). Teclea en el filtro: hoy el contador de TODAS las tarjetas visibles sube con cada tecla, aunque no hayan cambiado. Eso es desperdicio. Primero mide, luego memoiza solo donde el medidor acuse: para que las tarjetas dejen de re-renderizarse en balde.

Paso 1: Que el medidor deje de subir

  • Envuelves TarjetaHeroe en memo para que se salte el render cuando sus props no cambian.
  • Estabilizas onAñadir con useCallback, sin lo cual memo no serviría (la tarjeta vería una prop nueva en cada tecla).
  • Al teclear en el filtro, el contador de las tarjetas que siguen visibles ya no sube.
Ver soluciones
import { memo, useCallback, useState } from "react";

// Un héroe del roster del Team Builder.
interface Heroe {
  id: number;
  nombre: string;
  rol: string;
}

// El roster es constante y vive FUERA del componente: su referencia no cambia entre renders.
const ROSTER: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño" },
  { id: 2, nombre: "Mercy", rol: "Apoyo" },
  { id: 3, nombre: "Reinhardt", rol: "Tanque" },
  { id: 4, nombre: "Ana", rol: "Apoyo" },
  { id: 5, nombre: "Genji", rol: "Daño" },
  { id: 6, nombre: "Sigma", rol: "Tanque" },
];

// Medidor didáctico: cuántas veces se ha renderizado cada tarjeta.
const renders: Record<number, number> = {};

// La tarjeta, ahora envuelta en memo: solo se re-renderiza si sus props cambian
// (comparación superficial por referencia). Para que memo sirva de algo, sus props
// tienen que ser referencialmente estables entre renders.
const TarjetaHeroe = memo(function TarjetaHeroe({
  heroe,
  onAñadir,
}: {
  heroe: Heroe;
  onAñadir: (h: Heroe) => void;
}) {
  // Suma uno al medidor de ESTA tarjeta cada vez que se renderiza.
  renders[heroe.id] = (renders[heroe.id] || 0) + 1;
  return (
    <article className="tarjeta">
      <h3 className="tarjeta__nombre">{heroe.nombre}</h3>
      <p className="tarjeta__rol">{heroe.rol}</p>
      <p className="tarjeta__renders">renders: {renders[heroe.id]}</p>
      <button className="boton" onClick={() => onAñadir(heroe)}>
        Añadir
      </button>
    </article>
  );
});

export default function App() {
  // Texto del filtro: cambia con cada tecla, así que el padre se re-renderiza al teclear.
  const [filtro, setFiltro] = useState("");
  // El equipo que va eligiendo el usuario.
  const [equipo, setEquipo] = useState<Heroe[]>([]);

  // Filtrar 6 héroes es barato: se recalcula en cada render, sin memoizar.
  const visibles = ROSTER.filter((heroe) =>
    heroe.nombre.toLowerCase().includes(filtro.toLowerCase()),
  );

  // useCallback con [] devuelve SIEMPRE la misma referencia de función entre renders.
  // Sin esto, onAñadir sería una función nueva cada vez y memo no podría saltarse
  // ningún re-render: la tarjeta vería una prop "nueva" en cada tecla del filtro.
  const añadir = useCallback((heroe: Heroe) => {
    setEquipo((actual) =>
      actual.some((h) => h.id === heroe.id) ? actual : [...actual, heroe],
    );
  }, []);

  return (
    <section>
      <h1 className="titulo">
        Roster ({visibles.length}) · equipo: {equipo.length}
      </h1>
      <input
        className="filtro"
        placeholder="Filtra por nombre…"
        value={filtro}
        onChange={(evento) => setFiltro(evento.target.value)}
      />
      <div className="lista">
        {visibles.map((heroe) => (
          // key estable (el id del héroe), nunca el índice del map.
          <TarjetaHeroe key={heroe.id} heroe={heroe} onAñadir={añadir} />
        ))}
      </div>
    </section>
  );
}

Por qué este nivel

  • Las dos piezas van juntas: memo para que la tarjeta se salte el render, y useCallback para que la prop onAñadir mantenga su referencia. memo sin la función estable no sirve de nada, porque vería una prop nueva en cada tecla del filtro.
  • Su límite: arregla los re-renders pero no añade nada nuevo a la interfaz. El siguiente tier mete una función real (deshabilitar el botón) sin perder la optimización.