DEV Community

Cover image for De 3 segundos a 300ms: cómo optimicé el performance de una app Next.js en producción
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

De 3 segundos a 300ms: cómo optimicé el performance de una app Next.js en producción

Hay un momento específico en la vida de un desarrollador donde te das cuenta que rompiste algo. No con un error. Con silencio. Con lentitud. Con ese spinner que gira y gira mientras el usuario se pregunta si tu app está viva o ya murió.

Me pasó en producción. Una app Next.js que habíamos lanzado con orgullo estaba tardando entre 2.8 y 3.4 segundos en el First Contentful Paint. En mobile, peor. El LCP rondaba los 4 segundos. Google Lighthouse me miraba con cara de asco y yo no tenía excusas — era mi código, mis decisiones, mi problema.

Este es el relato de cómo diagnostiqué el desastre, qué cambié, y cómo llegué a 300ms de FCP en producción. Sin bullshit, sin "simplemente usá un CDN", con el trabajo sucio que nadie muestra en los tutoriales.

El diagnóstico: primero entendé qué está ardiendo

Antes de tocar una sola línea de código, necesitás saber qué está lento. Yo cometí el error clásico: asumir. "Seguro es el bundle", pensé. Spoiler: no era solo el bundle.

Las herramientas que usé:

  • Lighthouse en modo incógnito (sin extensiones que contaminen los resultados)
  • Chrome DevTools → Network tab con throttling a "Fast 3G"
  • Vercel Analytics para datos reales de usuarios
  • next build con ANALYZE=true para ver el bundle

Para el bundle analyzer, instalé esto:

npm install @next/bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

Y en next.config.js:

const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  // tu config
}

module.exports = withBundleAnalyzer(nextConfig)
Enter fullscreen mode Exit fullscreen mode

Después corrés:

ANALYZE=true npm run build
Enter fullscreen mode Exit fullscreen mode

Y ahí fue cuando vi el horror. Tenía moment.js importado completo — 67kb gzipped — para formatear dos fechas en toda la app. Tenía una librería de gráficos cargando en el bundle principal cuando solo aparecía en una página de dashboard. Tenía componentes que fetcheaban datos en el cliente cuando perfectamente podían ser Server Components.

El diagnóstico real mostró tres problemas grandes:

  1. Bundle de cliente inflado con dependencias innecesarias
  2. Waterfall de requests en el cliente (fetch tras fetch, en cadena)
  3. Imágenes sin optimizar y sin tamaño declarado (layout shift asesino)

Problema 1: el bundle era un desastre

Bye bye moment.js

Reemplacé moment.js con date-fns usando imports específicos:

// ❌ Antes — importaba todo moment
import moment from 'moment'
const fecha = moment(timestamp).format('DD/MM/YYYY')

// ✅ Después — solo lo que necesito
import { format } from 'date-fns'
import { es } from 'date-fns/locale'
const fecha = format(new Date(timestamp), 'dd/MM/yyyy', { locale: es })
Enter fullscreen mode Exit fullscreen mode

Resultado: -67kb gzipped del bundle principal. Sí, así de ridículo era.

Dynamic imports para lo que no se ve al inicio

El gráfico de dashboard no debería estar en el bundle de la página de inicio. Dynamic import con next/dynamic:

import dynamic from 'next/dynamic'

// ❌ Antes
import { RevenueChart } from '@/components/RevenueChart'

// ✅ Después
const RevenueChart = dynamic(
  () => import('@/components/RevenueChart'),
  {
    loading: () => <ChartSkeleton />,
    ssr: false // este componente usa window, no puede hacer SSR
  }
)
Enter fullscreen mode Exit fullscreen mode

Esto sacó ~45kb del bundle inicial y el usuario ve el skeleton mientras carga — mucho mejor UX que ver nada.

Problema 2: el waterfall de fetches en el cliente

Acá estaba el problema más gordo. Tenía una página de perfil de usuario que hacía esto:

// ❌ El horror — cada fetch espera al anterior
const ProfilePage = () => {
  const [user, setUser] = useState(null)
  const [posts, setPosts] = useState([])
  const [stats, setStats] = useState(null)

  useEffect(() => {
    fetch('/api/user')
      .then(r => r.json())
      .then(user => {
        setUser(user)
        // Espera al user para fetchear posts
        return fetch(`/api/posts?userId=${user.id}`)
      })
      .then(r => r.json())
      .then(posts => {
        setPosts(posts)
        // Espera a posts para fetchear stats
        return fetch(`/api/stats?userId=${user.id}`)
      })
      .then(r => r.json())
      .then(setStats)
  }, [])
}
Enter fullscreen mode Exit fullscreen mode

Tres requests en cadena. Esperaba request 1 para lanzar request 2. Esperaba request 2 para lanzar request 3. En una conexión normal eso son 800ms de overhead puro.

La solución en dos pasos:

Paso 1: Paralizar lo que se puede paralelizar

Si tenés el userId desde el principio (por ejemplo, de la sesión), no necesitás esperar a que llegue el user para pedir sus posts:

// ✅ Paralelo cuando es posible
const ProfilePage = ({ userId }: { userId: string }) => {
  useEffect(() => {
    Promise.all([
      fetch(`/api/user/${userId}`).then(r => r.json()),
      fetch(`/api/posts?userId=${userId}`).then(r => r.json()),
      fetch(`/api/stats?userId=${userId}`).then(r => r.json()),
    ]).then(([user, posts, stats]) => {
      setUser(user)
      setPosts(posts)
      setStats(stats)
    })
  }, [userId])
}
Enter fullscreen mode Exit fullscreen mode

Paso 2: Moverlo al servidor con Server Components (la solución real)

Pero la solución de verdad era dejar de fetchear en el cliente. Con el App Router de Next.js 13+, esto se convierte en:

// app/perfil/[userId]/page.tsx
// ✅ Server Component — todo en el servidor, en paralelo
import { getUserData, getUserPosts, getUserStats } from '@/lib/api'

export default async function ProfilePage({ 
  params 
}: { 
  params: { userId: string } 
}) {
  // Paralelo en el servidor — no hay waterfall, no hay round trip al cliente
  const [user, posts, stats] = await Promise.all([
    getUserData(params.userId),
    getUserPosts(params.userId),
    getUserStats(params.userId),
  ])

  return (
    <div>
      <UserHeader user={user} />
      <StatsBar stats={stats} />
      <PostsList posts={posts} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Esto eliminó completamente el round trip cliente → servidor para el data fetching inicial. El HTML llega al navegador ya con los datos adentro. El tiempo de esos tres fetches dejó de contar para el usuario.

Problema 3: las imágenes me estaban matando

Tenía imágenes con <img> nativo en vez de next/image. Sin width/height declarados. Sin lazy loading inteligente. El Cumulative Layout Shift era de 0.34 — Google te odia si superás 0.1.

// ❌ Layout shift garantizado
<img src={user.avatar} alt={user.name} />

// ✅ Next.js Image con todo configurado
import Image from 'next/image'

<Image
  src={user.avatar}
  alt={user.name}
  width={64}
  height={64}
  className="rounded-full"
  priority={false} // true solo para imágenes above the fold
/>
Enter fullscreen mode Exit fullscreen mode

Para las imágenes hero (above the fold), usé priority={true} para que Next.js las precargue. Para todo lo demás, lazy loading automático.

También configuré los dominios permitidos en next.config.js:

module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'storage.googleapis.com',
        pathname: '/mi-bucket/**',
      },
    ],
    formats: ['image/avif', 'image/webp'],
  },
}
Enter fullscreen mode Exit fullscreen mode

Next.js convierte automáticamente a WebP/AVIF según lo que soporte el browser. Mis imágenes de 800kb bajaron a 120kb en WebP.

El toque final: caching agresivo

Venía cacheando prácticamente nada. Las rutas del App Router tienen cache por defecto, pero yo lo estaba rompiendo sin querer:

// ❌ Esto desactiva el cache estático
export const dynamic = 'force-dynamic'

// ✅ Revalidación cada 60 segundos — fresco pero cacheado
export const revalidate = 60
Enter fullscreen mode Exit fullscreen mode

Para el fetch dentro de Server Components, usé las opciones de cache:

// Cache con revalidación por tiempo
const data = await fetch('https://api.ejemplo.com/data', {
  next: { revalidate: 3600 } // 1 hora
})

// Cache estático (no cambia nunca hasta el próximo deploy)
const config = await fetch('https://api.ejemplo.com/config', {
  cache: 'force-cache'
})

// Sin cache (datos en tiempo real)
const liveData = await fetch('https://api.ejemplo.com/live', {
  cache: 'no-store'
})
Enter fullscreen mode Exit fullscreen mode

Los resultados reales

Una semana después del deploy con todos los cambios, los números de Vercel Analytics:

Métrica Antes Después Mejora
FCP (p75) 3.1s 310ms -90%
LCP (p75) 4.2s 820ms -80%
CLS 0.34 0.02 -94%
Bundle size 487kb 198kb -59%
TTFB 890ms 180ms -80%

El score de Lighthouse pasó de 42 a 91. En mobile, de 31 a 84.

Lo que más impactó, en orden:

  1. Server Components eliminando el client waterfall (40% de la mejora)
  2. Bundle splitting y eliminación de dependencias pesadas (30%)
  3. Optimización de imágenes (20%)
  4. Caching (10%)

Lo que aprendí — y lo que hubiera hecho diferente

El error fundamental fue no medir desde el principio. Desarrollé meses asumiendo que "estaba bien" y recién en producción con usuarios reales vi el desastre. Ahora tengo Lighthouse en el CI/CD que falla el build si el score baja de 80.

También aprendí que optimizar performance no es un sprint, es una mentalidad. Cada dependencia que agregás tiene un costo. Cada fetch en el cliente tiene un costo. Cada imagen sin dimensiones tiene un costo. El costo se paga después, con usuarios frustrados y SEO en el piso.

La optimización de performance en Next.js no es magia — es diagnóstico honesto, decisiones conservadoras con las dependencias, y aprovechar las herramientas que ya tenés. Los Server Components existen para esto. El Image component existe para esto. El bundle analyzer existe para esto.

Usalos antes de que el Lighthouse te grite.


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)