Cambiar tema

Guía completa de optimización del rendimiento de APIs en Next.js: caché, streaming y edge computing

Easton editorial illustration: state-management shelf

El viernes a las nueve de la noche, el product manager compartió una captura en el grupo. En el vídeo de prueba, el tester abrió la lista del blog en el móvil: el icono de carga giró cinco segundos enteros y la pantalla seguía en blanco. En la esquina inferior derecha decía: «¿De qué década es esta web?»

Abrí Chrome DevTools y, vaya, la petición a la API tardaba 3200 ms. Me entró un poco de pánico: sabía que esa ruta era lenta, pero no había tenido tiempo de optimizarla; no imaginaba que fuera tan grave.

Luego dediqué dos días a estudiar la optimización de rendimiento en Next.js y descubrí que no es tan complicado. Con la estrategia de caché adecuada, más streaming y edge computing, el tiempo de respuesta bajó de 3 segundos a menos de 500 ms. Lo más importante: entendí cuándo usar cada enfoque — eso importa más que conocer todas las tecnologías.

Hoy repasamos tres palancas: cómo elegir la caché, cómo implementar streaming y en qué casos encajan las Edge Functions. El código está probado en producción y las cifras son reales; puedes usarlo tal cual.

¿Por qué tu API de Next.js va tan lenta?

Empecemos por los cuellos de botella habituales. Al depurar esa API de 3 segundos encontré varios problemas típicos:

Consultas a la base de datos sin optimizar. Había un bucle que, por cada artículo, consultaba al autor por separado: el clásico N+1. Cien artículos, cien peticiones a la base de datos; normal que vaya lento. Peor aún: algunas tablas ni siquiera tenían índices.

Cero caché. Cada vez que el usuario refrescaba, el servidor volvía a consultar la base de datos, recalcular y reformatear. La configuración cambiaba una vez al mes y, aun así, se recalculaba cada segundo.

Devolver todo de golpe. La API devolvía el contenido completo de cien artículos, incluido el cuerpo. La respuesta JSON superaba 2 MB; solo la transferencia ya costaba un segundo. En la lista no hace falta el cuerpo, solo título y resumen.

Ubicación del servidor. Lo teníamos en la costa oeste de EE. UU.; para usuarios en China, ida y vuelta desde 200 ms, más el efecto del GFW… mejor no entrar en detalle.

Los cambios de caché en Next.js 16

En octubre de 2025, Next.js 16 trajo un cambio importante: pasó de caché implícita a caché explícita.

Antes Next.js cacheaba muchas cosas solo, cómodo en teoría, pero en la práctica generaba dudas: ¿esto está cacheado? ¿Cuánto tiempo? ¿Cómo limpiarlo? A veces los datos ya estaban actualizados y la página seguía mostrando lo viejo; tras depurar descubrías que era la caché.

Ahora tienes que decirle explícitamente a Next.js qué cachear y durante cuánto. Un poco más de trabajo, pero al menos sabes qué ocurre y tienes control.

Tres direcciones para optimizar

Con los problemas claros, las palancas también:

  1. Caché: no repitas lo que ya hiciste
  2. Streaming: calcula y transmite en paralelo; no esperes a tener todo listo
  3. Edge computing: acerca el servidor al usuario

Vamos una por una.

Estrategias de caché: elegir bien ahorra trabajo

Next.js tiene cuatro mecanismos de caché: Request Memoization, Data Cache, Full Route Cache y Router Cache. La primera vez que leí la documentación también me perdí.

No hace falta memorizarlos todos. En API Routes, lo más útil es Data Cache — cachear resultados de consultas o respuestas de APIs externas.

Escenario 1: caché de datos estáticos

Configuración del sitio, listas de categorías… datos que casi no cambian. Puedes cachearlos una hora o más.

// app/api/categories/route.js
export async function GET() {
  const data = await fetch('https://api.example.com/categories', {
    next: { revalidate: 3600 } // Caché de 1 hora
  })

  return Response.json(await data.json())
}

Así de simple. revalidate: 3600 significa una hora de caché y luego refresco automático.

500ms → 50ms
Tiempo de respuesta -90%
Tras cachear la lista de categorías, la mayoría de peticiones devuelven caché sin tocar la base de datos

Escenario 2: caché de datos de usuario

El perfil del usuario no cambia a menudo, pero tampoco puedes servir datos obsoletos para siempre. Aquí encaja stale-while-revalidate:

// app/api/user/profile/route.js
export async function GET(request) {
  const user = await getUserFromDB()

  return new Response(JSON.stringify(user), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 's-maxage=60, stale-while-revalidate=300'
    }
  })
}

La idea es inteligente: devuelves la caché (aunque esté un poco caducada) y actualizas en segundo plano. El usuario no nota latencia y los datos no quedan demasiado viejos.

s-maxage=60: la caché es fresca durante 60 segundos. stale-while-revalidate=300: hasta 300 segundos más puedes servir la versión antigua mientras se actualiza en background.

Escenario 3: no cachear datos en tiempo real

Cotizaciones, mensajes de chat… mejor sin caché o con WebSocket / Server-Sent Events.

export async function GET() {
  const price = await getStockPrice()

  return new Response(JSON.stringify(price), {
    headers: {
      'Cache-Control': 'no-store' // Sin caché
    }
  })
}

Invalidación de caché: ¿qué hacer tras actualizar datos?

El usuario actualiza su perfil y la caché sigue mostrando lo antiguo. Hay que invalidarla a mano.

Next.js ofrece revalidateTag y revalidatePath:

// app/api/user/update/route.js
import { revalidateTag } from 'next/cache'

export async function POST(request) {
  const data = await request.json()
  await updateUserProfile(data)

  // Invalidar la caché relacionada con el usuario
  revalidateTag('user-profile')

  return Response.json({ success: true })
}

En la ruta de consulta, etiqueta la caché:

export async function GET() {
  const data = await fetch('db-api/user', {
    next: {
      revalidate: 3600,
      tags: ['user-profile'] // Etiqueta
    }
  })

  return Response.json(await data.json())
}

Tras actualizar el perfil, la caché relacionada expira y la siguiente petición trae datos frescos.

Errores habituales

Error 1: caché excesiva. Vi a alguien cachear el estado del pedido una hora; el usuario pagaba y tardaba media hora en ver el estado actualizado. El TTL debe ajustarse al tipo de dato, no cuanto más largo mejor.

Error 2: olvidar el precalentamiento. La primera petición sigue siendo lenta porque la caché está vacía. Tras el despliegue, conviene invocar la ruta una vez para precargar datos calientes.

Error 3: claves de caché mal diseñadas. Se cachean datos del usuario A y el usuario B recibe los de A. La clave debe incluir el ID de usuario u otros discriminadores.

Respuesta en streaming: listas grandes sin bloqueos

La caché evita recalcular, pero a veces el cálculo es lento o el volumen es grande. Ahí entra el streaming.

¿Qué es el streaming?

Una API tradicional es como un restaurante que sirve todos los platos a la vez cuando el más lento está listo. Diez platos, esperas al más tardío.

El streaming sirve plato a plato: el cliente empieza a comer mientras llegan los siguientes. El tiempo total puede ser similar, pero no esperas con la mesa vacía.

Para el usuario, pasa de «pantalla en blanco tres segundos» a «en 500 ms ya veo las primeras filas y puedo navegar». La sensación cambia por completo.

¿Cuándo usar streaming?

Escenarios típicos:

  1. Listas largas: productos, artículos, resultados de búsqueda
  2. Contenido generado por IA: el efecto máquina de escribir de ChatGPT es streaming
  3. Archivos grandes: exportar Excel, generar PDF
  4. Logs en tiempo real: build logs, progreso de tareas

En general, si hay muchos datos o el cálculo tarda, merece la pena considerarlo.

¿Cómo implementarlo en Next.js?

Lo más habitual es ReadableStream:

// app/api/posts/stream/route.js
export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      // Obtener datos por lotes
      for (let page = 0; page < 5; page++) {
        // 20 registros por lote
        const posts = await fetchPostsFromDB({ page, limit: 20 })

        // Enviar este lote
        const chunk = JSON.stringify(posts) + '\n'
        controller.enqueue(encoder.encode(chunk))

        // Simular tiempo de procesamiento
        await new Promise(r => setTimeout(r, 100))
      }

      // Fin del envío
      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Transfer-Encoding': 'chunked'
    }
  })
}

No es complejo. Lo esencial:

  1. Crear ReadableStream
  2. En start, obtener datos por lotes
  3. controller.enqueue() por cada lote
  4. controller.close() al terminar

¿Cómo consumirlo en el frontend?

El cliente también debe leer el stream:

async function fetchStreamData() {
  const response = await fetch('/api/posts/stream')
  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  let allPosts = []

  while (true) {
    const { done, value } = await reader.read()

    if (done) {
      console.log('Recepción de datos completada')
      break
    }

    // Decodificar
    const chunk = decoder.decode(value)

    // Parsear JSON (una línea por lote)
    const posts = JSON.parse(chunk)
    allPosts = [...allPosts, ...posts]

    // Actualizar la UI al vuelo
    updatePostList(allPosts)
  }
}

Al abrir la página, la lista se va llenando poco a poco en lugar de quedarse en blanco.

Comparación de resultados reales

Tras añadir streaming a la lista del blog:

2800ms → 500ms
Tiempo hasta el primer contenido visible
Antes: 2800 ms y todo de golpe; después: 500 ms para las primeras 20 entradas y navegación inmediata

El tiempo total solo mejoró unos 1300 ms, pero la percepción es mucho mayor. A los 500 ms ya puedes interactuar; el resto lo pasas leyendo, no esperando.

Un truco extra

Con volúmenes enormes, combina scroll virtual (Virtual Scrolling): solo renderizas lo visible. Aunque lleguen 1000 filas, la página no se traba.

En React: react-window o react-virtualized. En Vue: vue-virtual-scroller.

Edge Functions: la API en la puerta del usuario

Caché y streaming optimizan en software; hay un enfoque más directo: acercar el servidor al usuario.

El impacto de la distancia física

La latencia de red depende sobre todo de la distancia. La luz tiene límite: un paquete de Pekín a la costa oeste de EE. UU. son al menos 200 ms de ida y vuelta; es física, no hay magia.

Antes solo podías desplegar en un sitio fijo, p. ej. Alibaba Cloud en Pekín: rápido allí, lento en EE. UU.

Las Edge Functions despliegan código en decenas o cientos de nodos; el tráfico va al más cercano. Usuario en Pekín → nodo en Pekín; en Nueva York → nodo en Nueva York. Latencia por debajo de 50 ms.

Edge Runtime vs Node.js Runtime

Las API Routes de Next.js usan por defecto Node.js Runtime: todas las APIs de Node (fs, crypto, conexiones a base de datos, etc.).

Edge Runtime se basa en V8 (el motor de Chrome), no es Node.js completo. Arranque ultrarrápido (0-5 ms), pero muchas APIs de Node no están disponibles.

Comparación rápida:

CaracterísticaNode.js RuntimeEdge Runtime
Arranque100-500ms0-5ms
APIs disponiblesTodas las de Node.jsLimitadas (solo Web estándar)
Casos de usoLógica compleja, base de datosLógica ligera, auth, proxy
Latencia globalDepende del despliegueGlobal <50ms
MemoriaMayorMenor (128MB)

¿Qué casos encajan en Edge Functions?

No toda API debe migrar a Edge. Escenarios típicos:

Escenario 1: autenticación

Ideal para Edge: comprobar JWT, validar API key. Lógica ligera en el borde; peticiones inválidas no llegan al servidor central.

// app/api/auth/route.js
export const runtime = 'edge'

export async function GET(request) {
  const token = request.headers.get('authorization')

  if (!token) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Validar token (p. ej. con jose, compatible con Edge)
  const isValid = await verifyToken(token)

  if (!isValid) {
    return new Response('Invalid token', { status: 401 })
  }

  return Response.json({ user: 'authenticated' })
}
200ms → 20ms
Latencia de auth -90%
Validación rápida en el borde; peticiones inválidas no alcanzan el servidor central

Escenario 2: personalización geográfica

Según la IP del usuario: idioma, moneda, recomendaciones.

export const runtime = 'edge'

export async function GET(request) {
  // Geolocalización (Vercel la inyecta automáticamente)
  const country = request.geo?.country || 'US'
  const city = request.geo?.city || 'Unknown'

  // Contenido según ubicación
  const content = getLocalizedContent(country)

  return Response.json({
    country,
    city,
    content,
    currency: country === 'CN' ? 'CNY' : 'USD'
  })
}

Sin base de datos; procesamiento en el borde, muy rápido.

Escenario 3: proxy de API

El frontend llama a varias APIs externas; en Edge puedes agregarlas y reducir peticiones del cliente.

export const runtime = 'edge'

export async function GET(request) {
  // Peticiones en paralelo
  const [weather, news] = await Promise.all([
    fetch('https://api.weather.com/...'),
    fetch('https://api.news.com/...')
  ])

  return Response.json({
    weather: await weather.json(),
    news: await news.json()
  })
}

Una petición del usuario; el backend paraleliza y baja la latencia total.

Escenario 4: pruebas A/B

Decidir en el borde qué variante servir sin tocar la app principal.

export const runtime = 'edge'

export async function GET(request) {
  const userId = request.headers.get('x-user-id')

  // A/B simple
  const variant = parseInt(userId) % 2 === 0 ? 'A' : 'B'

  const content = variant === 'A' ? getContentA() : getContentB()

  return Response.json({ variant, content })
}

Limitaciones de Edge Functions

Si son tan buenas, ¿por qué no migrar todo? Porque hay límites:

Límite 1: APIs exclusivas de Node.js

fs, path, child_process no están disponibles. Si tu código los usa, fallará en Edge.

Límite 2: conexión a base de datos

Conexiones clásicas (pg, mysql2) dependen del módulo net de Node. Usa soluciones HTTP:

  • Prisma Data Proxy
  • PlanetScale (MySQL)
  • Supabase (PostgreSQL)
  • Redis (API HTTP)

Límite 3: memoria y tiempo de ejecución

Suele haber límite de 128 MB y 30 s. No encaja para cálculos pesados o datos masivos.

Mi recomendación: uso mixto

No hace falta elegir uno u otro. Yo uso:

  • Capa edge: auth, geolocalización, proxy simple
  • Capa central (Node.js): lógica de negocio, base de datos, archivos

Edge filtra lo inválido y lo simple; lo complejo va al servidor central. Menos latencia sin renunciar a capacidades.

Mediciones reales

Según un benchmark publicado en Medium:

  • Vercel Edge Functions: latencia media 48,3 ms
  • Cloudflare Workers (custom): latencia media 36,37 ms
  • API Node.js tradicional (una región): latencia media 200-500 ms

Edge es más rápido, pero depende de dónde estén tus usuarios. Si todos están en China, un servidor tradicional en China puede ser mejor.

Caso práctico: optimizar la API de lista de artículos del blog

Juntemos las tres técnicas con el ejemplo del principio: la lista del blog que tardaba tanto.

Problemas antes de optimizar

Código original:

// app/api/posts/route.js
export async function GET() {
  // Problema 1: consulta a BD en cada petición, sin caché
  const posts = await db.post.findMany({
    take: 100,
    include: {
      author: true, // Problema 2: consulta N+1
      tags: true
    }
  })

  // Problema 3: contenido completo, payload enorme
  return Response.json(posts)
}

Datos de rendimiento:

  • Tiempo de respuesta: 2800 ms
  • Tamaño JSON: 2,3 MB
  • Experiencia: pantalla en blanco ~3 s

Paso 1: optimizar consultas a la base de datos

Primero el N+1 y solo campos necesarios:

export async function GET() {
  const posts = await db.post.findMany({
    take: 100,
    select: {
      id: true,
      title: true,
      summary: true,  // Solo resumen, no cuerpo completo
      createdAt: true,
      author: {
        select: { name: true, avatar: true }
      }
    }
  })

  return Response.json(posts)
}

Resultado: 800 ms de respuesta; JSON de 2,3 MB a 180 KB.

Paso 2: añadir caché

La lista cambia poco; caché de 5 minutos:

export async function GET() {
  const posts = await db.post.findMany({
    // ... igual que arriba
  }, {
    next: {
      revalidate: 300,  // Caché 5 minutos
      tags: ['posts']
    }
  })

  return Response.json(posts)
}

Al publicar, invalidar caché:

// app/api/posts/publish/route.js
import { revalidateTag } from 'next/cache'

export async function POST(request) {
  const newPost = await request.json()
  await db.post.create({ data: newPost })

  // Invalidar caché de la lista
  revalidateTag('posts')

  return Response.json({ success: true })
}

Resultado: con caché acertada, 50 ms; carga del servidor -90%.

Paso 3: pasar a streaming

Aunque ya va mejor, la primera visita (sin caché) sigue esperando ~800 ms. Streaming:

export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      const batchSize = 20

      for (let page = 0; page < 5; page++) {
        const posts = await db.post.findMany({
          skip: page * batchSize,
          take: batchSize,
          select: { /* igual que arriba */ }
        })

        const chunk = JSON.stringify(posts) + '\n'
        controller.enqueue(encoder.encode(chunk))
      }

      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'application/x-ndjson', // Newline Delimited JSON
      'Cache-Control': 's-maxage=300, stale-while-revalidate=600'
    }
  })
}

Resultado: primer lote en ~300 ms; el usuario navega mientras llega el resto; 800 ms totales pero casi sin espera percibida.

Paso 4: auth en Edge (opcional)

Si hace falta auth, validación inicial en Edge:

// app/api/posts/route.js (capa auth Edge)
export const runtime = 'edge'

export async function GET(request) {
  const token = request.headers.get('authorization')

  if (!token) {
    return new Response('Unauthorized', { status: 401 })
  }

  // OK: reenviar a la API real (Node.js Runtime)
  return fetch(`${process.env.API_BASE_URL}/posts/internal`, {
    headers: { authorization: token }
  })
}

Peticiones inválidas se cortan en el borde.

Comparación de resultados

MétricaAntesDespuésMejora
Primera visita2800ms300ms (primer lote)89% ↓
Con caché-50ms98% ↓
Tamaño JSON2,3MB180KB92% ↓
Tiempo hasta interacción2800ms300ms89% ↓
Carga del servidor100%10%90% ↓

Ya nadie pregunta «¿de qué década es esta web?».

Monitorización y mejora continua

Optimizar no es el final; hay que medir de forma continua.

Métricas clave

Me fijo en estas:

  1. Distribución del tiempo de respuesta (P50, P95, P99)

    • P50 (mediana): la mitad de los usuarios
    • P95: el 95%
    • P99: el 1% más lento (a veces anomalías)
  2. Tasa de acierto de caché

    • <70%: revisar estrategia
    • >95%: quizá TTL demasiado largo, datos poco frescos
  3. Tasa de error

    • No debe subir tras optimizar
    • Ojo con fallos a mitad de stream
  4. Distribución geográfica

    • Latencia por región
    • Decide si necesitas Edge Functions

Herramientas de monitorización

Vercel Analytics: si despliegas en Vercel, métricas por API incluidas.

Next.js Instrumentation API (novedad 2026): puntos de medición en código:

// instrumentation.js
export function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    require('./monitoring')
  }
}

// monitoring.js
export function onRequestEnd(info) {
  console.log(`API ${info.url} took ${info.duration}ms`)

  // Enviar a la plataforma de monitorización
  sendToMonitoring({
    url: info.url,
    duration: info.duration,
    status: info.status
  })
}

Logs propios: simple pero efectivo:

export async function GET() {
  const start = Date.now()

  const data = await fetchData()

  const duration = Date.now() - start
  console.log(`API /posts took ${duration}ms`)

  return Response.json(data)
}

Recomendaciones de mejora continua

  1. Revisar la caché con regularidad: el negocio cambia, la estrategia también
  2. Pruebas A/B: si dudas entre dos enfoques, mídelos
  3. Decidir con datos reales: no solo intuición; mira el dashboard

La optimización de rendimiento es un proceso continuo, no un proyecto único.

Resumen

En pocas palabras:

Estrategia de caché: según el tipo de dato. Caché larga para estáticos, stale-while-revalidate para usuario, nada para tiempo real. Invalida tras actualizar.

Streaming: cuando hay volumen o cálculo lento. El usuario ve contenido antes; menos pantalla en blanco. Mejor con scroll virtual en el frontend.

Edge Functions: auth, geolocalización, proxy ligero. No sustituyen Node.js para lógica pesada; combínalas.

La optimización es incremental. Empieza por la ruta más lenta, aplica estas tres palancas, mide y ajusta. No busques la perfección de un solo golpe.

Mi lista del blog pasó de 3 s a 300 ms; la diferencia se nota. Elige una API lenta y empieza hoy. Si tienes dudas, comenta; avanzamos juntos.

FAQ

¿Cuándo expira la caché de una API en Next.js?
Hay tres formas: 1) expiración temporal (cuando llega el tiempo de revalidate), 2) invalidación manual (revalidateTag o revalidatePath), 3) recarga forzada del usuario (Ctrl+Shift+R). Las dos primeras son las más habituales; ajusta revalidate según la frecuencia de actualización de los datos.
¿El streaming sirve para todas las APIs?
No. Encaja cuando hay muchos datos (listas largas) o cálculos lentos (generación con IA). Si los datos son pocos y el cálculo rápido, una respuesta tradicional basta. Criterio: tiempo de respuesta >1 s o JSON >500 KB.
¿Qué limitaciones tienen las Edge Functions?
Tres principales: 1) no puedes usar APIs exclusivas de Node.js (fs, child_process), 2) la base de datos debe ir por HTTP (p. ej. Prisma Data Proxy), 3) límite de 128 MB de memoria y 30 s de ejecución. Ideales para autenticación y proxy; la lógica compleja sigue en Node.js Runtime.
¿Cómo elegir la estrategia de caché?
Mira la frecuencia de actualización: caché larga (1 h+) para datos estáticos (config, categorías), stale-while-revalidate (60 s frescos + 300 s de actualización en segundo plano) para datos de usuario, sin caché o WebSocket para datos en tiempo real (cotizaciones). Más caché = mejor rendimiento, pero datos más desactualizados.
¿Cómo verificar el efecto tras optimizar?
Cuatro métricas: 1) tiempo de respuesta (P50, P95, P99), 2) tasa de acierto de caché (objetivo 70-95%), 3) tasa de error (no debe subir), 4) latencia por región. Usa Vercel Analytics, Next.js Instrumentation API o logs propios. Haz A/B entre antes y después.

15 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog