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 buildconANALYZE=truepara ver el bundle
Para el bundle analyzer, instalé esto:
npm install @next/bundle-analyzer
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)
Después corrés:
ANALYZE=true npm run build
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:
- Bundle de cliente inflado con dependencias innecesarias
- Waterfall de requests en el cliente (fetch tras fetch, en cadena)
- 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 })
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
}
)
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)
}, [])
}
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])
}
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>
)
}
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
/>
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'],
},
}
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
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'
})
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:
- Server Components eliminando el client waterfall (40% de la mejora)
- Bundle splitting y eliminación de dependencias pesadas (30%)
- Optimización de imágenes (20%)
- 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.
Comentarios (0)
Deja un comentario
Artículos Relacionados
Metí un LLM chico adentro de una app Next.js y esto fue lo que aprendí
Reproducí el experimento del LLM tiny que explotó en Show HN: Gemma corriendo en el browser, sin API keys, desde mi stack habitual. Acá está todo lo que salió mal — y lo poco que salió bien.
De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js
Empecé tocando una Amiga 500 a los 3 años sin entender nada. Hoy hago deploy en segundos desde una terminal. En el medio: cyber cafés, servidores Linux a las 3am, y un pivot de carrera que cambió todo. Esta es mi historia.
El stack tecnológico perfecto en 2025: lo que elegiría si arrancara un proyecto hoy
Después de años rompiendo cosas en producción, acá está mi stack ideal para 2025. Sin hype, sin vendor lock-in innecesario, y con las cicatrices suficientes para justificar cada decisión.