Back

Trucos de Rendimiento Frontend que Olvidamos

Trucos de Rendimiento Frontend que Olvidamos

La forma más rápida de ralentizar un frontend moderno es asumir que el framework se encarga del rendimiento por ti. La mayoría de las técnicas de bajo nivel que definían los sitios web rápidos hace una década — dimensiones explícitas de imágenes, scripts de terceros diferidos, indicadores de font-display, preconexiones manuales — siguen siendo relevantes, pero ahora el framework se interpone entre tú y la plataforma, y la abstracción presenta fugas de formas que una auditoría de Lighthouse señalará con gusto. En 2019, ButterCMS escribió que “los navegadores pronto admitirán la carga diferida de forma nativa.” Ese futuro llegó, se convirtió en estándar y luego, silenciosamente, dejó de ser algo que verificamos. Este artículo repasa los fundamentos de rendimiento frontend que delegamos y los modos de fallo en producción que aparecen cuando esa delegación falla.

Conclusiones Clave

  • loading="lazy" nativo ha sido compatible con todos los navegadores principales desde Safari 15.4 (marzo de 2022), por lo que cualquier envoltorio de Intersection Observer escrito antes de esa fecha es código muerto que añade peso al bundle.
  • Google Fonts puede servir fuentes con font-display: swap, pero los bloques @font-face personalizados en tu propio CSS no heredan ese comportamiento — cada uno es un potencial destello de texto invisible en conexiones lentas.
  • setTimeout(fn, 0) se ejecuta en la siguiente tarea y puede aterrizar en medio de una interacción del usuario; requestIdleCallback espera un período de inactividad genuino, lo que lo convierte en el primitivo correcto para trabajo no urgente.
  • Usa defer para scripts que interactúan con el DOM y async solo para scripts verdaderamente independientes, ya que los scripts async se ejecutan en el orden de llegada por red, no en el orden del documento.
  • Desde que INP reemplazó a FID como Core Web Vital en marzo de 2024, los manejadores de scroll y resize sin limitación de frecuencia que bloquean el hilo principal son ahora una señal de posicionamiento, no solo un problema de fluidez.

width/height Explícitos en Imágenes Sigue Previniendo el Desplazamiento de Diseño

En aplicaciones React donde las dimensiones de las imágenes provienen de respuestas de API en tiempo de ejecución, el navegador no tiene espacio reservado para asignar, por lo que cada imagen que carga después del primer renderizado es un potencial desplazamiento de diseño — independientemente de si el componente de imagen de tu framework gestiona correctamente los recursos estáticos. El Cumulative Layout Shift es uno de los Core Web Vitals definidos por la documentación de CLS de web.dev, y el modo de fallo visible es concreto: la página salta cuando carga una imagen y el toque del usuario aterriza en el botón equivocado.

La abstracción que presenta fugas aquí es el componente de imagen del framework. <Image> de Next.js reserva espacio cuando le pasas width y height, pero no hace nada con las etiquetas <img> sin procesar en contenido MDX, HTML renderizado por un CMS, o cualquier marcado que el componente nunca toca. Los navegadores modernos calculan un aspect-ratio implícito a partir de los atributos width y height (MDN documenta este comportamiento), por lo que las dimensiones reservan espacio incluso cuando CSS sobreescribe el tamaño renderizado.

// Antes: las dimensiones llegan en tiempo de ejecución, nada reserva espacio
<img src={product.imageUrl} alt={product.name} />

// Después: los atributos establecen una caja de aspect-ratio antes de que cargue la imagen
<img
  src={product.imageUrl}
  alt={product.name}
  width={product.width}
  height={product.height}
  style={{ width: '100%', height: 'auto' }}
/>

Cuando la API omite las dimensiones, establece un aspect-ratio en el contenedor. De cualquier manera, el espacio existe antes de que lleguen los bytes.

font-display: swap y Preconnect para Fuentes Personalizadas

Cada bloque @font-face personalizado sin font-display: swap es un potencial destello de texto invisible — FOIT — en conexiones lentas, donde un párrafo permanece en blanco durante toda la duración de la descarga de la fuente. El descriptor font-display controla esto directamente: swap renderiza texto de respaldo de inmediato y cambia a la fuente personalizada una vez que carga, produciendo un destello de texto sin estilo (FOUT) en su lugar, tal como se describe en la referencia de font-display de MDN.

La fuga es por delegación. Google Fonts puede inyectar font-display: swap en el CSS que sirve cuando la URL de la hoja de estilos incluye el parámetro display apropiado, por lo que los equipos que usan la hoja de estilos alojada nunca piensan en ello — y luego escriben sus propios bloques @font-face para fuentes de marca que no heredan ese comportamiento. Una fuente autoalojada sin el descriptor envía FOIT a cada visitante con caché fría.

El autoalojamiento también elimina la preconexión que la hoja de estilos de Google implícitamente fomentaba. La guía de web.dev sobre conexiones de red tempranas recomienda preconectar al origen de la fuente para que los handshakes de DNS, TCP y TLS terminen antes de que la URL de la fuente sea descubierta en el CSS.

@font-face {
  font-family: "BrandSans";
  src: url("/fonts/brand-sans.woff2") format("woff2");
  font-display: swap; /* el texto de respaldo se muestra de inmediato, sin FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />

Audita cada bloque @font-face que hayas escrito manualmente. El hábito del CSS alojado oculta los que necesitan corrección.

preconnect y dns-prefetch para Orígenes de Terceros

Los bundlers y frameworks gestionan preconnect para sus propios orígenes de CDN, pero los endpoints de analíticas de terceros, CDNs de imágenes y servicios de pruebas A/B son invisibles para el paso de compilación — sus consultas DNS ocurren en el momento de la solicitud a menos que añadas <link rel="preconnect"> manualmente. ButterCMS describió el mecanismo con precisión en 2019: preconnect le indica al navegador que complete la consulta DNS, la conexión inicial y la negociación TLS “lo antes posible, en lugar de más tarde cuando se descubre la etiqueta script.”

El costo del handshake de DNS y TLS no ha desaparecido; el framework simplemente dejó de recordártelo. Un endpoint de Segment, un origen de Cloudinary o un gestor de etiquetas de terceros requieren cada uno una configuración de conexión nueva que bloquea el recurso detrás de él. Usa preconnect para orígenes que sabes que visitarás pronto, y dns-prefetch como una indicación más ligera para orígenes que podrías visitar, ya que preconnect abre una conexión completa que pagas independientemente de si se usa. web.dev cubre la diferencia entre las dos indicaciones.

<!-- Origen crítico de terceros: abrir la conexión completa ahora -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />

<!-- Origen probable pero no seguro: solo resolver DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />

Coloca estos elementos en la parte superior del <head>, antes de los scripts y hojas de estilos que desencadenan las solicitudes.

loading="lazy" Nativo Reemplazó tu Envoltorio de Intersection Observer

loading="lazy" nativo ha sido compatible con todos los navegadores principales desde que Safari 15.4 llegó en marzo de 2022 — cualquier envoltorio de Intersection Observer escrito antes de esa fecha es ahora código muerto que añade peso al bundle y superficie de mantenimiento. Chrome lo incluyó en la versión 77 (agosto de 2019) y Firefox en la versión 75 (abril de 2020), según la tabla de compatibilidad de navegadores en la referencia del elemento img de MDN.

La fuga aquí es histórica, no específica del framework. Las bases de código acumularon hooks useLazyImage y componentes <LazyImage> en los años anteriores a que el atributo alcanzara el estándar base, y esos componentes siguen enviándose — ejecutando un observer por imagen, manteniendo refs y re-renderizando en la intersección — para hacer lo que el navegador ahora hace de forma nativa y fuera del hilo principal. El mismo atributo funciona en iframes, lo que importa para mapas embebidos y reproductores de video debajo del pliegue.

// Antes: un observer manual que la plataforma hizo redundante
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) setVisible(true);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}

// Después: el navegador lo gestiona, fuera del hilo principal
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;

Mantén las dimensiones explícitas — las imágenes con carga diferida desplazan el diseño con la misma facilidad que las cargadas de forma anticipada.

defer vs async en Scripts de Terceros

La regla práctica de decisión: usa defer para cualquier script que lea o escriba en el DOM, y async solo para scripts que son verdaderamente independientes — porque los scripts async se ejecutan en el orden de llegada por red, no en el orden del documento, y dos scripts async con una dependencia entre ellos competirán en una condición de carrera. La definición del elemento script en el HTML Living Standard especifica que los scripts defer se ejecutan después de que el análisis del documento se completa, en el orden del documento, mientras que los scripts async se ejecutan tan pronto como terminan de descargarse.

La fuga es social, no técnica: alguien pega un fragmento de analíticas de un proveedor en <head> exactamente como lo muestran las instrucciones de copia y pegado del proveedor, sin ningún atributo, y un <script> simple bloquea el análisis del documento hasta que se descarga y ejecuta. El modo de fallo visible es el retraso en la interacción. Cuando los scripts de terceros bloquean la interacción, las repeticiones muestran toques repetidos en el mismo control — un patrón textbook de rage-click.

AtributoMomento de ejecuciónGarantía de ordenUsar para
ningunoBloquea el parser inmediatamenteOrden del documentoCasi nunca
asyncTan pronto como se descargaOrden de llegada por redAnalíticas independientes
deferDespués de completar el análisisOrden del documentoTodo lo que toca el DOM
<!-- Antes: bloquea el parser, retrasa el primer renderizado y la interacción -->
<script src="https://vendor.example/analytics.js"></script>

<!-- Después: script independiente, nunca bloquea el parser -->
<script src="https://vendor.example/analytics.js" async></script>

requestIdleCallback en Lugar de setTimeout(fn, 0)

setTimeout(fn, 0) programa trabajo en el siguiente slot de la cola de tareas, que puede aterrizar justo en medio de una interacción del usuario; requestIdleCallback espera un período de inactividad genuino, lo que lo convierte en el primitivo correcto para la inicialización de analíticas, la hidratación de prefetch y el procesamiento por lotes de telemetría. La distinción está documentada en la referencia de requestIdleCallback de MDN: el callback se activa durante los períodos de inactividad del navegador y recibe un deadline que puedes verificar antes de hacer más trabajo.

Este es el primitivo que la mayoría de los equipos nunca adoptó — setTimeout(fn, 0) se convirtió en el idioma reflexivo de “hacer esto después”, y en realidad no cede el paso al usuario. Desde que INP reemplazó a FID como Core Web Vital en marzo de 2024 (según el anuncio de INP de web.dev), el trabajo en el hilo principal que aterriza durante una interacción ya no es solo un problema de fluidez — es una señal de posicionamiento. requestIdleCallback es compatible con Chrome y Firefox pero no con Safari, así que detecta la característica y proporciona un fallback.

function whenIdle(fn) {
  if ("requestIdleCallback" in window) {
    requestIdleCallback(fn, { timeout: 2000 });
  } else {
    setTimeout(fn, 0); // Fallback para Safari
  }
}

// Diferir trabajo no urgente fuera del camino de interacción
whenIdle(() => initAnalytics());

La opción timeout garantiza que el trabajo eventualmente se ejecute incluso si el navegador nunca entra en inactividad.

Debounce y Throttle en Scroll, Resize e Input

Los manejadores de scroll, resize e input sin limitación de frecuencia que bloquean el hilo principal son ahora una señal de posicionamiento, no solo un problema de fluidez — cada frame que retrasan es una potencial violación de INP. El patrón se rompió porque useEffect hace que adjuntar un listener sin procesar sea trivial: tres líneas, sin limitación de frecuencia, y un manejador que se activa en cada frame de scroll.

Debounce ejecuta una función después de que la actividad se detiene — correcto para inputs de búsqueda y trabajo al finalizar el resize. Throttle limita la frecuencia — correcto para el seguimiento de posición de scroll que debe actualizarse durante el gesto. La referencia del evento scroll de MDN señala que los eventos de scroll pueden dispararse a alta frecuencia y recomienda limitar los manejadores costosos.

useEffect(() => {
  let ticking = false;
  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      updateScrollPosition(window.scrollY);
      ticking = false;
    });
  }
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);

La barrera de requestAnimationFrame limita a una actualización por frame, y { passive: true } le indica al navegador que el manejador no llamará a preventDefault, permitiéndole hacer scroll sin esperar a tu JavaScript.

El Patrón de Efecto Compuesto

Cada técnica en este artículo es conocimiento de plataforma que delegamos a un valor predeterminado del framework y dejamos de verificar. Ninguna es nueva — ese es el punto. Individualmente, un font-display faltante o una etiqueta sin diferir cuesta milisegundos; juntos representan la diferencia entre una aplicación que se siente rápida y una que se siente pesada a pesar de las herramientas modernas. La siguiente acción concreta: abre DevTools, audita tus bloques @font-face escritos a mano, tus etiquetas <script> de terceros y tus listeners de useEffect según las reglas anteriores, y elimina el envoltorio de Intersection Observer que el navegador hizo redundante.

Preguntas Frecuentes

Usa debounce cuando solo te importa el estado final después de que la actividad se detiene, como enviar una solicitud de búsqueda después de que el usuario deja de escribir o recalcular el diseño después de que termina un resize. Usa throttle cuando necesitas actualizaciones durante un gesto continuo a una frecuencia limitada, como el seguimiento de la posición de scroll. Debounce espera una pausa; throttle limita la frecuencia mientras el evento sigue disparándose.

Sí. El atributo loading aplica tanto a elementos img como a iframe, por lo que los mapas embebidos, reproductores de video y widgets de terceros debajo del pliegue pueden diferir la carga de forma nativa sin un envoltorio de Intersection Observer. La compatibilidad del navegador sigue de cerca el despliegue de imágenes, alcanzando el estándar base en Chrome, Firefox y Safari en la misma era. Mantén width y height explícitos para prevenir el desplazamiento de diseño, ya que los elementos con carga diferida se desplazan con la misma facilidad que los cargados de forma anticipada.

Compiten en una condición de carrera y pueden ejecutarse en el orden incorrecto. Los scripts async se ejecutan tan pronto como cada uno termina de descargarse, en el orden de llegada por red en lugar del orden del documento, por lo que un script que depende de otro script async puede ejecutarse primero y fallar. La solución es usar defer para ambos scripts, lo que garantiza la ejecución después de que el análisis del documento se completa y en el orden del documento, o cargar la dependencia antes del script dependiente en un único bundle.

setTimeout con un retraso de cero programa trabajo en el siguiente slot de la cola de tareas, que el navegador puede ejecutar de inmediato, incluso en medio de una interacción del usuario, por lo que en realidad no cede el paso al usuario. requestIdleCallback espera un período de inactividad genuino y pasa un deadline que puedes verificar antes de continuar. Desde que INP se convirtió en Core Web Vital en marzo de 2024, esta distinción importa porque el trabajo que aterriza durante una interacción es ahora una señal de posicionamiento.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay