Disponible para freelance¡Contáctame para que pueda ayudar a que tu negocio crezca o convertir tu idea en realidad!

Estoy interesado
Técnicas de Optimización de Performance Web que Todo Frontend Debería Conocer

Técnicas de Optimización de Performance Web que Todo Frontend Debería Conocer

Una vez un cliente me preguntó por qué la página de su producto tardaba 6 segundos en volverse interactiva en un celular de gama media. Abrí el bundle analyzer y encontré una librería de date-picker, un set de íconos completo y una librería de gráficos — todo cargado en una página que no mostraba nada de eso hasta que el usuario hacía clic en "Agregar al carrito". Eso no es un caso raro. Es la mayoría de las aplicaciones React que he auditado.

El trabajo de performance no se trata de micro-optimizar loops de render. El noventa por ciento de las veces, se trata de enviar menos JavaScript y cargarlo en el momento correcto.

A dónde se van realmente los milisegundos

Antes de tocar código, entiende qué estás optimizando. Dos métricas importan más para el usuario:

  • Time to Interactive (TTI) — cuándo la página responde a clics y toques, no solo cuándo parece lista
  • Largest Contentful Paint (LCP) — cuándo el elemento visible más grande (normalmente una imagen destacada o un título) termina de renderizarse

Ambas se ven destruidas por la misma causa raíz: demasiado JavaScript siendo parseado y ejecutado antes de que el navegador pueda hacer algo útil. Un bundle de 500KB no solo tarda más en descargarse — tarda más en parsearse y ejecutarse, y eso bloquea el hilo principal en dispositivos de gama baja mucho más de lo que bloquea en tu laptop con chip M-series.

Prueba con CPU limitada (desaceleración 4x en Chrome DevTools) y una conexión 3G limitada. Tu máquina rápida te miente sobre lo que experimenta el usuario real.


Las tres palancas que realmente marcan la diferencia

1. Code splitting

No necesitas toda la aplicación en un solo bundle. Divide por ruta de forma automática, y divide por feature manualmente para todo lo pesado y condicional.

// ❌ Se carga en cada página, aunque el modal nunca se abra
import { ExportModal } from '@/components/ExportModal'

function Dashboard() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Exportar</button>
      {open && <ExportModal onClose={() => setOpen(false)} />}
    </>
  )
}
// ✅ Solo se trae cuando el usuario realmente hace clic en Exportar
import dynamic from 'next/dynamic'

const ExportModal = dynamic(() => import('@/components/ExportModal'), {
  loading: () => <Spinner />,
})

function Dashboard() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Exportar</button>
      {open && <ExportModal onClose={() => setOpen(false)} />}
    </>
  )
}

Ese único cambio sacó por completo las dependencias del ExportModal — una librería de PDF, en este caso — del bundle inicial. En el proyecto del cliente que mencioné, esto solo redujo el payload inicial de JS en un 40%.

2. Entrega de imágenes

Las imágenes suelen ser los bytes más pesados de la página, y son lo más fácil de arreglar mal.

// ❌ Imagen en resolución completa, sin lazy loading, sin dimensiones explícitas
<img src="/hero-banner.jpg" alt="Destacado del producto" />
// ✅ Responsiva, lazy por defecto debajo del pliegue, el tamaño explícito evita layout shift
import Image from 'next/image'

<Image
  src="/hero-banner.jpg"
  alt="Destacado del producto"
  width={1200}
  height={600}
  priority // solo para imágenes por encima del pliegue
/>

Usa priority únicamente en la imagen que determina tu LCP — normalmente la imagen principal. Marcar todo como priority anula el propósito; terminas cargando todo de forma anticipada otra vez.

3. Scripts de terceros

Analytics, widgets de chat, herramientas de A/B testing — estos suelen ser el verdadero culpable, no tu propio código. Cada uno agrega su propia petición de red, su propio costo de parseo de JS, y a veces su propio comportamiento de bloqueo de render.

// ❌ Bloquea el parsing de inmediato, corre antes de que la página sea interactiva
<script src="https://widget.example.com/chat.js"></script>
// ✅ Carga después de que la página ya es interactiva, no compite por el hilo principal en la primera carga
import Script from 'next/script'

<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />

La prop strategy de next/script te da un control que la mayoría de los equipos no sabe que existe: beforeInteractive, afterInteractive o lazyOnload. Por defecto, deja los scripts de terceros en lazyOnload, a menos que algo dependa de que estén listos de inmediato.


Midiendo el impacto real

No adivines. Corre Lighthouse o next build con el bundle analyzer antes y después:

ANALYZE=true npm run build

En la auditoría que mencioné antes, así quedaron los números:

MétricaAntesDespués
Bundle de JS inicial780 KB410 KB
Time to Interactive (3G, celular de gama media)6.1s2.8s
Largest Contentful Paint3.4s1.6s

Mismas funcionalidades. Mismo diseño. El único cambio fue posponer lo que no necesitaba cargar de inmediato.


Errores comunes

  • Optimizar useMemo/useCallback antes de revisar el tamaño del bundle. El performance de re-render rara vez importa si la página ya tardó 4 segundos en volverse interactiva. Resuelve primero el problema más grande.
  • Hacer lazy-load de todo, incluyendo el contenido por encima del pliegue. Si importas dinámicamente tu sección principal, retrasas justo lo que determina tu puntaje de LCP. Haz lazy load de lo que está oculto, no de lo que ya es visible de entrada.
  • Importar una librería entera para una sola función. import _ from 'lodash' trae toda la librería aunque solo uses debounce. Usa import debounce from 'lodash/debounce' o una implementación nativa.
  • Ignorar el impacto de un script de terceros porque "no es tu código". Al usuario no le importa de quién es el código que hizo lenta la página. Audita cada script tag igual que auditas tu propio bundle.

Buenas prácticas

  • Define un presupuesto de tamaño de bundle y hazlo cumplir en CI. Herramientas como bundlesize o las advertencias nativas de Next.js detectan regresiones antes de que se publiquen, no después de que un usuario se queje.
  • Usa next/dynamic por defecto para todo lo que esté debajo del pliegue o detrás de una interacción — modales, pestañas que no están activas en el momento, gráficos, editores de texto enriquecido.
  • Comprime y sirve formatos de imagen modernos. WebP o AVIF en lugar de JPEG/PNG reduce bastante el peso de la imagen sin pérdida de calidad visible en la mayoría de los casos.
  • Haz preconnect a los orígenes de terceros que no puedas evitar. <link rel="preconnect" href="https://fonts.googleapis.com"> quita el handshake de DNS/TLS del camino crítico.
  • Vuelve a medir después de cada dependencia "pequeña" que agregues. Una sola librería de date-picker puede sumar 80KB. Revisa antes de hacer merge, no después de que la aplicación ya se sienta lenta.

Qué hacer ahora

Corre un bundle analyzer en tu aplicación hoy — no en el próximo sprint. Encuentra los tres chunks más grandes que no sean tu UI principal, y pregúntate si necesitan cargar en el primer paint. En la mayoría de los casos, la respuesta es no, y moverlos detrás de un dynamic() o lazyOnload toma quince minutos por componente.

Haz esto de forma consistente, y el tamaño del bundle deja de ser un proyecto trimestral de limpieza para convertirse en un hábito que atrapa regresiones antes de que lleguen a producción.