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í:
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 medirFí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.
// 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:
// 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:
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.
<!-- 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.
// 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:
- 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.
- 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.
- 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.
- 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.
Paso 2: Una función real sin romper la optimización
- El botón Añadir se desactiva si el héroe ya está en el equipo (prop enEquipo, un booleano).
- Al añadir un héroe, solo se re-renderiza la tarjeta afectada; las demás siguen quietas.
- Las props que pasas a la tarjeta siguen siendo referencialmente estables.
Paso 3: Disciplina de medición
- Memoizas solo lo que el medidor señala: NO envuelves el filtro de 6 héroes en useMemo, y explicas por qué en un comentario.
- Atás el campo a una etiqueta accesible y la interfaz aguanta a 375px.
- Dejas la nota honesta del React Compiler: con él escribirías esto sin memo ni useCallback.
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.
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, envuelta en memo. Ahora recibe también enEquipo: un booleano (primitivo),
// no un objeto. Eso importa, porque memo compara props por referencia: un primitivo
// "cambia" solo cuando su valor cambia, así que solo se re-renderiza la tarjeta afectada.
const TarjetaHeroe = memo(function TarjetaHeroe({
heroe,
enEquipo,
onAñadir,
}: {
heroe: Heroe;
enEquipo: boolean;
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>
{/* El botón se desactiva si el héroe ya está en el equipo. */}
<button className="boton" onClick={() => onAñadir(heroe)} disabled={enEquipo}>
{enEquipo ? "En el equipo" : "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()),
);
// Referencia estable de la función: la prop onAñadir no cambia entre renders, así que
// memo puede saltarse el re-render de las tarjetas que no han cambiado.
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) => (
<TarjetaHeroe
// key estable (el id del héroe), nunca el índice del map.
key={heroe.id}
heroe={heroe}
// enEquipo es un booleano: solo cambia para la tarjeta afectada cuando el
// equipo cambia, así que memo deja quietas a TODAS las demás.
enEquipo={equipo.some((h) => h.id === heroe.id)}
onAñadir={añadir}
/>
))}
</div>
</section>
);
} Por qué es mejor que el anterior
- Añade una feature de verdad —el botón se desactiva si el héroe ya está en el equipo— sin romper memo. La clave es que enEquipo es un BOOLEANO (primitivo): solo "cambia" para la tarjeta afectada cuando el equipo cambia, así que las demás siguen quietas.
- Si en vez de un booleano le pasaras un objeto nuevo en cada render, memo dejaría de funcionar. El detalle de qué tipo de prop pasas es lo que hace que la optimización aguante.
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 de renders por tarjeta. En una app real este medidor es el Profiler
// de las React DevTools: grabas la interacción y ves qué se renderizó de más.
const renders: Record<number, number> = {};
// La tarjeta, envuelta en memo, con props referencialmente estables (heroe del roster
// constante, enEquipo primitivo, onAñadir de useCallback). Por eso memo de verdad funciona.
const TarjetaHeroe = memo(function TarjetaHeroe({
heroe,
enEquipo,
onAñadir,
}: {
heroe: Heroe;
enEquipo: boolean;
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>
{/* El botón se desactiva si el héroe ya está en el equipo. */}
<button className="boton" onClick={() => onAñadir(heroe)} disabled={enEquipo}>
{enEquipo ? "En el equipo" : "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 gratis: NO lo envuelvo en useMemo. El medidor decía que el
// problema eran los re-renders de las tarjetas, no este filtro; memoizarlo solo añadiría
// una comparación y ruido sin mover ninguna métrica. Memoizar lo que ya es barato es el error.
const visibles = ROSTER.filter((heroe) =>
heroe.nombre.toLowerCase().includes(filtro.toLowerCase()),
);
// La función SÍ se memoiza: es una prop de un hijo envuelto en memo, así que su
// estabilidad referencial es justo lo que evita los re-renders en balde. Aquí sí se mide.
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>
{/* La etiqueta da un nombre accesible al campo (Nivel 7: accesibilidad en React). */}
<label className="filtro-label">
Filtrar héroes por nombre
<input
className="filtro"
placeholder="Filtra por nombre…"
value={filtro}
onChange={(evento) => setFiltro(evento.target.value)}
/>
</label>
<div className="lista">
{visibles.map((heroe) => (
<TarjetaHeroe
// key estable (el id del héroe), nunca el índice del map.
key={heroe.id}
heroe={heroe}
enEquipo={equipo.some((h) => h.id === heroe.id)}
onAñadir={añadir}
/>
))}
</div>
</section>
);
}
// Nota honesta: con el React Compiler (plugin de build opt-in, Nivel 7) escribirías este
// componente SIN memo ni useCallback y tendrías la misma optimización. Memoizar a mano sirve
// para leer y mantener el código de hoy; el de mañana lo escribes simple y la herramienta mide. Por qué es mejor que el anterior
- La disciplina del capítulo, hecha código: memoiza lo que el medidor señala (la tarjeta y su función) y NO lo que ya es barato (el filtro de 6 héroes). El comentario lo deja explícito: memoizar lo barato solo añade ruido sin mover ninguna métrica.
- Cierra con accesibilidad (el campo atado a su etiqueta, Nivel 7) y aguanta a 375px, el estándar de un excelente de UI.
- Y la nota honesta: con el React Compiler escribirías el componente simple, sin memo ni useCallback, y tendrías la misma optimización. Memoizar a mano es, sobre todo, saber leer el código de hoy.