Cambiar tema

Aplicación de chat en tiempo real con Next.js: la forma correcta de usar WebSocket y SSE

Easton editorial illustration: performance inspection lens

Tercer intento de hacer que Next.js soporte WebSocket. La página devuelve error: “WebSocket is not supported in this environment”. El chat funcionaba perfectamente en local; al desplegar en Vercel, todo se vino abajo.

Entonces caí en la cuenta de algo: la comunicación en tiempo real en Next.js no es tan sencilla como parece.

Este artículo no te enseñará a forzar WebSocket en Next.js (ya recorrí ese camino y no funciona). Comparto los problemas reales que he encontrado: por qué Vercel no admite WebSocket, si SSE es la salvación, y cómo hacer que Socket.io conviva con App Router. Si te estás rompiendo la cabeza con funciones en tiempo real en Next.js, espero que esto te ahorre algunos rodeos.

Comparativa de tres esquemas de comunicación en tiempo real

Salas de chat, edición colaborativa, notificaciones en tiempo real: todas estas funciones requieren que el servidor envíe datos al cliente de forma proactiva. El modelo petición-respuesta de HTTP no lo resuelve, así que tenemos tres opciones habituales.

WebSocket: el sueño full-duplex

WebSocket es el esquema ideal: una sola conexión y cliente y servidor pueden intercambiar mensajes en cualquier momento. ¿Por qué dudar?

Yo también lo pensé así. Hasta el momento del despliegue.

Plataformas Serverless como Vercel y Netlify no admiten conexiones persistentes. Tu app Next.js corre en funciones en la nube; cuando termina la petición, la función se recicla — ¿cómo mantener una conexión WebSocket? Probé servicios WebSocket de terceros (Pusher, Ably), pero el coste mensual me frenó.

Dicho esto, WebSocket no está descartado. Puedes alquilar un servidor propio o ejecutar un backend Node.js separado para WebSocket. Ambos caminos funcionan, aunque el coste y la arquitectura son más complejos.

SSE: un canal unidireccional de emergencia

Server-Sent Events: por el nombre ya se entiende — unidireccional, solo del servidor al cliente.

Suena limitado: enviar mensajes por HTTP POST y recibirlos por SSE. Pero encaja en muchos escenarios: en una sala de chat envías de forma activa y recibes de forma pasiva; las notificaciones en tiempo real solo necesitan push del servidor.

Lo más importante: SSE funciona en Vercel.

Hice un sistema de notificaciones sencillo con SSE y resolví el gran problema del despliegue en Vercel. Tiene limitaciones (Edge Function con timeout de 25 segundos), pero para push de mensajes basta.

Long Polling: viejo pero efectivo

El esquema más antiguo: el cliente hace una petición, el servidor espera hasta tener un mensaje nuevo y responde, y el cliente lanza inmediatamente la siguiente petición.

Rendimiento peor y más tráfico desperdiciado. Pero si tienes unos cientos de usuarios y pocos mensajes, Long Polling funciona bien — código simple, sin problemas de compatibilidad, y las plataformas Serverless no lo rechazan.

He visto proyectos pequeños aguantar con Long Polling sin que la experiencia de usuario sufriera. No te dejes asustar por la palabra “deuda técnica”: lo que resuelve el problema es una buena solución.

Tabla comparativa: de un vistazo

Bajo
Sobrecarga de conexión WebSocket
Un handshake, conexión persistente
Simple
Complejidad de implementación SSE
Soporte nativo del navegador con EventSource
Total
Compatibilidad de Long Polling
Compatible con todos los navegadores
SSE primero
Compatibilidad con despliegue en Vercel
WebSocket no soportado
CaracterísticaWebSocketSSELong Polling
Comunicación bidireccional❌ (requiere POST)
Soporte en Vercel✅ (con límite de timeout)
Compatibilidad del navegadorNavegadores modernosNavegadores modernosTodos
Sobrecarga de conexiónBajaBajaAlta
Complejidad de implementaciónMediaSimpleMuy simple
Escenario adecuadoChat, juegos, edición colaborativaNotificaciones, actualizaciones en tiempo realMensajes de baja frecuencia, alta compatibilidad

Quizá ya lo notaste: con Next.js + Vercel, SSE y Long Polling son las opciones habituales. No es un retroceso técnico, sino soluciones pragmáticas dentro de las limitaciones de la plataforma.

Cómo elegir el esquema de comunicación en tiempo real en Next.js

Conocer las tres opciones es una cosa; elegir otra. Aquí resumo varios puntos de decisión — todos aprendidos a base de tropiezos.

La plataforma de despliegue es la primera barrera

Si usas Vercel, Netlify, Cloudflare Pages u otras plataformas Serverless, WebSocket casi no es viable. Tienes tres caminos:

  1. Esquema SSE: para push unidireccional (notificaciones, actualizaciones en tiempo real, recepción de mensajes en salas de chat)
  2. Long Polling: para comunicación bidireccional de baja frecuencia
  3. Servicio WebSocket independiente: un servidor pequeño solo para WebSocket, con la app Next.js en Serverless

En mis proyectos, salvo comunicación bidireccional de alta frecuencia (edición colaborativa), elijo SSE. Despliegue simple y bolsillo tranquilo.

Si tienes servidor propio (VPS, Docker), WebSocket sin restricciones — aunque entonces te ocupas de balanceo de carga y gestión de procesos.

El volumen de usuarios define la complejidad de la arquitectura

¿Cuántos usuarios tienes en línea a la vez?

  • < 100 personas: Long Polling basta; código tan simple que lo escribes en una hora
  • 100-1000 personas: SSE o WebSocket sencillo
  • > 1000 personas: cola de mensajes (Redis Pub/Sub), balanceo de carga, despliegue multi-instancia

Conocí un equipo que lanzó la primera versión con Long Polling y migró a SSE al llegar a 500 usuarios. Bien hecho: validar rápido al inicio y optimizar después.

La frecuencia de mensajes influye en la elección

Para un sistema de notificaciones con pocos mensajes por minuto, Long Polling basta. En una sala de chat con decenas de personas enviando a la vez, SSE o WebSocket son más adecuados.

Un detalle fácil de pasar por alto: el navegador limita las conexiones HTTP al mismo dominio (normalmente 6). Con Long Polling o SSE, varias pestañas pueden bloquearse. Usa WebSocket o detección de pestaña única.

El presupuesto también cuenta

Servicios WebSocket de terceros (Pusher, Ably, PubNub) son cómodos pero se facturan por volumen de mensajes. Calculé que una sala con 500 usuarios en línea costaba $49-$99 al mes solo en WebSocket.

Con despliegue propio:

  • Vercel + SSE: cuota gratuita amplia, proyectos pequeños sin coste
  • VPS + WebSocket: desde $5/mes (Vultr, DigitalOcean)
  • Railway/Render: soporte WebSocket, $5-$10/mes

Mi árbol de decisión (como referencia)

¿Despliegue en Vercel?
├─ Sí → ¿Usuarios > 1000?
│   ├─ Sí → SSE + Redis Pub/Sub
│   └─ No → SSE simple o Long Polling
└─ No (autoalojado) → ¿Comunicación bidireccional de alta frecuencia?
    ├─ Sí → WebSocket + Socket.io
    └─ No → SSE

Al final, no hay elección técnicamente perfecta. Mi experiencia: lanza con la solución más simple y optimiza cuando realmente lo necesites. Muchos proyectos que empezaron con clúster WebSocket nunca llegaron a 100 usuarios.

Integración práctica con Socket.io

Si decides usar WebSocket (por ejemplo con servidor propio), Socket.io es la librería más madura. Degrada automáticamente a Long Polling e incluye reconexión y gestión de salas.

Integrar Socket.io en Next.js tiene más trampas de lo que parece. El Route Handler de App Router no admite res.socket; necesitas Custom Server.

Paso 1: crear Custom Server

El arranque por defecto de Next.js no soporta WebSocket; hace falta un servidor personalizado. Crea server.js en la raíz del proyecto:

// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;

const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  });

  // Inicializar Socket.io
  const io = new Server(httpServer, {
    cors: {
      origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
      methods: ['GET', 'POST']
    }
  });

  // Manejo de conexiones
  io.on('connection', (socket) => {
    console.log('Usuario conectado:', socket.id);

    // Unirse a sala
    socket.on('join_room', (roomId) => {
      socket.join(roomId);
      console.log(`Usuario ${socket.id} se unió a la sala ${roomId}`);
    });

    // Recibir mensaje
    socket.on('send_message', (data) => {
      // Enviar a todos en la sala (incluido el emisor)
      io.to(data.room).emit('receive_message', {
        id: Date.now(),
        user: data.user,
        message: data.message,
        timestamp: new Date().toISOString()
      });
    });

    socket.on('disconnect', () => {
      console.log('Usuario desconectado:', socket.id);
    });
  });

  httpServer.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on http://${hostname}:${port}`);
  });
});

Modifica package.json:

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

Paso 2: conexión del cliente

Crea un Hook que encapsule la lógica del cliente Socket.io:

// hooks/useSocket.ts
'use client';

import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';

export function useSocket() {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const socketInstance = io('http://localhost:3000', {
      transports: ['websocket', 'polling'] // WebSocket primero, degradar a polling
    });

    socketInstance.on('connect', () => {
      console.log('Socket conectado');
      setIsConnected(true);
    });

    socketInstance.on('disconnect', () => {
      console.log('Socket desconectado');
      setIsConnected(false);
    });

    setSocket(socketInstance);

    return () => {
      socketInstance.disconnect();
    };
  }, []);

  return { socket, isConnected };
}

Paso 3: componente de chat

// app/chat/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { useSocket } from '@/hooks/useSocket';

interface Message {
  id: number;
  user: string;
  message: string;
  timestamp: string;
}

export default function ChatPage() {
  const { socket, isConnected } = useSocket();
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputMessage, setInputMessage] = useState('');
  const [username] = useState(`Usuario${Math.floor(Math.random() * 1000)}`);
  const roomId = 'general'; // Sala fija; en proyectos reales puede ser dinámica

  useEffect(() => {
    if (!socket) return;

    // Unirse a la sala
    socket.emit('join_room', roomId);

    // Escuchar nuevos mensajes
    socket.on('receive_message', (data: Message) => {
      setMessages((prev) => [...prev, data]);
    });

    return () => {
      socket.off('receive_message');
    };
  }, [socket, roomId]);

  const sendMessage = () => {
    if (!socket || !inputMessage.trim()) return;

    socket.emit('send_message', {
      room: roomId,
      user: username,
      message: inputMessage
    });

    setInputMessage('');
  };

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="mb-4">
        <span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
        <span className="ml-2">{isConnected ? 'Conectado' : 'Desconectado'}</span>
      </div>

      <div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
        {messages.map((msg) => (
          <div key={msg.id} className="mb-2">
            <span className="font-semibold text-blue-600">{msg.user}:</span>
            <span className="ml-2">{msg.message}</span>
            <span className="ml-2 text-xs text-gray-500">
              {new Date(msg.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="Escribe un mensaje..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          onClick={sendMessage}
          disabled={!isConnected}
          className="bg-blue-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
        >
          Enviar
        </button>
      </div>
    </div>
  );
}

Trampas que he encontrado

  1. Problema de hot reload: en desarrollo, cada guardado desconecta Socket. Es efecto secundario del hot reload de Next.js; no se evita del todo, hay que acostumbrarse.

  2. Errores CORS: si cliente y servidor usan puertos distintos, configura cors en Socket.io.

  3. Tipos TypeScript: instala @types/socket.io-client o los tipos serán un desastre.

  4. Despliegue: ¡Custom Server no se despliega en Vercel! Usa VPS o plataformas con WebSocket (Railway, Render).

Implementación con SSE (Server-Sent Events)

SSE fue mi salvación para funciones en tiempo real en Vercel. Código mucho más simple que WebSocket y sin Custom Server.

Servidor: Route Handler con SSE

El Route Handler de App Router puede devolver ReadableStream, ideal para SSE:

// app/api/sse/route.ts
import { NextRequest } from 'next/server';

// Cola de mensajes simulada (en producción usar Redis Pub/Sub)
const messageQueue: { id: string; message: string }[] = [];
const listeners = new Set<(message: any) => void>();

export async function GET(request: NextRequest) {
  // Crear flujo legible
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      // Mensaje inicial de conexión
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
      );

      // Escuchar nuevos mensajes
      const listener = (message: any) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
        );
      };

      listeners.add(listener);

      // Heartbeat periódico para evitar desconexión
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(`: heartbeat\n\n`));
      }, 15000); // Cada 15 segundos

      // Limpieza
      request.signal.addEventListener('abort', () => {
        listeners.delete(listener);
        clearInterval(heartbeat);
        controller.close();
      });
    }
  });

  // Respuesta SSE
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    }
  });
}

// POST para enviar mensajes
export async function POST(request: NextRequest) {
  const body = await request.json();

  const message = {
    id: Date.now().toString(),
    user: body.user,
    message: body.message,
    timestamp: new Date().toISOString()
  };

  // Notificar a todos los listeners
  listeners.forEach(listener => listener(message));

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

Cliente: consumir SSE con EventSource

// app/sse-chat/page.tsx
'use client';

import { useState, useEffect, useRef } from 'react';

interface Message {
  id: string;
  user: string;
  message: string;
  timestamp: string;
}

export default function SSEChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputMessage, setInputMessage] = useState('');
  const [isConnected, setIsConnected] = useState(false);
  const [username] = useState(`Usuario${Math.floor(Math.random() * 1000)}`);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    // Establecer conexión SSE
    const eventSource = new EventSource('/api/sse');
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      console.log('Conexión SSE establecida');
      setIsConnected(true);
    };

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.type === 'connected') {
        console.log('Confirmación de conexión del servidor');
        return;
      }

      // Nuevo mensaje recibido
      setMessages((prev) => [...prev, data]);
    };

    eventSource.onerror = () => {
      console.error('Error de conexión SSE');
      setIsConnected(false);
    };

    // Limpieza
    return () => {
      eventSource.close();
    };
  }, []);

  const sendMessage = async () => {
    if (!inputMessage.trim()) return;

    try {
      await fetch('/api/sse', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          user: username,
          message: inputMessage
        })
      });

      setInputMessage('');
    } catch (error) {
      console.error('Error al enviar mensaje:', error);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="mb-4">
        <span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
        <span className="ml-2">{isConnected ? 'SSE conectado' : 'SSE desconectado'}</span>
      </div>

      <div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
        {messages.map((msg) => (
          <div key={msg.id} className="mb-2">
            <span className="font-semibold text-purple-600">{msg.user}:</span>
            <span className="ml-2">{msg.message}</span>
            <span className="ml-2 text-xs text-gray-500">
              {new Date(msg.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="Escribe un mensaje..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          onClick={sendMessage}
          disabled={!isConnected}
          className="bg-purple-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
        >
          Enviar
        </button>
      </div>
    </div>
  );
}

Experiencia real con SSE

SSE no es perfecto. Al usarlo encontré varios problemas:

  1. Vercel tiene timeout de 25 segundos: la Edge Function se corta tras 25 segundos. Mi solución: reconexión automática en el cliente al detectar desconexión.

  2. Límite de conexiones del navegador: máximo 6 conexiones HTTP/1.1 por dominio. Varias pestañas pueden bloquearse. Solución: HTTP/2 (Vercel lo soporta por defecto) o detección de pestaña única.

  3. Broadcast de mensajes: el código anterior funciona en una sola instancia, pero Vercel despliega varias. Para broadcast real necesitas Redis Pub/Sub o Upstash.

Broadcast entre instancias con Redis

// lib/redis.ts
import { Redis } from '@upstash/redis';

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!
});

// app/api/sse/route.ts (versión mejorada)
import { redis } from '@/lib/redis';

export async function GET(request: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      // Suscripción Redis
      const channelName = 'chat_messages';

      // Polling Redis (Upstash no admite SUBSCRIBE nativo)
      const interval = setInterval(async () => {
        const messages = await redis.lrange(channelName, 0, -1);
        // Procesar mensajes...
      }, 1000);

      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  });
}

¿Cuándo usar SSE?

Mi recomendación:

  • ✅ Sistemas de notificaciones en tiempo real
  • ✅ Push de precios de acciones
  • ✅ Visualización de logs en vivo
  • ✅ Actualización de barras de progreso
  • ❌ Chat bidireccional de alta frecuencia (usar WebSocket)
  • ❌ Juegos online (usar WebSocket)

Estados de mensaje y sincronización de datos

La comunicación en tiempo real no es solo enviar y recibir. El usuario pregunta: ¿se envió mi mensaje? ¿lo vio el otro? ¿se pierden mensajes si se corta la red?

Detrás están la gestión de estados y la sincronización. Al implementar chat, me atascé varios días en esto.

Cuatro estados de mensaje

Inspirado en WeChat, un mensaje tiene al menos cuatro estados:

  1. Enviando: el usuario pulsa enviar y aún no hay respuesta del servidor
  2. Enviado: el servidor lo recibió, pero el destinatario quizá aún no
  3. Entregado: el cliente del destinatario lo recibió
  4. Fallido: error de red o rechazo del servidor

Gestionamos con una máquina de estados sencilla:

// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';

export interface Message {
  id: string;
  localId: string; // ID temporal generado en el cliente
  user: string;
  content: string;
  timestamp: string;
  status: MessageStatus;
}

Actualización optimista + reintentos

No esperes la respuesta del servidor para mostrar el mensaje — la experiencia es mala. Actualización optimista: mostrar estado “enviando” antes de enviar y actualizar tras confirmación.

// hooks/useChat.ts
'use client';

import { useState } from 'react';
import { Message, MessageStatus } from '@/types/message';

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);

  const sendMessage = async (content: string, username: string) => {
    // Generar ID temporal
    const localId = `local_${Date.now()}_${Math.random()}`;

    // Actualización optimista: mostrar al instante
    const tempMessage: Message = {
      id: '',
      localId,
      user: username,
      content,
      timestamp: new Date().toISOString(),
      status: 'sending'
    };

    setMessages((prev) => [...prev, tempMessage]);

    try {
      // Enviar al servidor
      const response = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ user: username, message: content })
      });

      const data = await response.json();

      // Actualizar a "enviado"
      setMessages((prev) =>
        prev.map((msg) =>
          msg.localId === localId
            ? { ...msg, id: data.id, status: 'sent' }
            : msg
        )
      );
    } catch (error) {
      // Envío fallido
      setMessages((prev) =>
        prev.map((msg) =>
          msg.localId === localId ? { ...msg, status: 'failed' } : msg
        )
      );
    }
  };

  const retryMessage = async (localId: string) => {
    const message = messages.find((msg) => msg.localId === localId);
    if (!message) return;

    // Restablecer a "enviando"
    setMessages((prev) =>
      prev.map((msg) =>
        msg.localId === localId ? { ...msg, status: 'sending' } : msg
      )
    );

    // Reenviar
    await sendMessage(message.content, message.user);
  };

  return { messages, sendMessage, retryMessage };
}

Reconexión y persistencia de mensajes

Con red inestable, la conexión se corta. ¿Cómo evitar pérdida de mensajes al reconectar?

Mi enfoque: IndexedDB en el cliente para mensajes no confirmados, reenvío tras reconectar.

// lib/indexedDB.ts
const DB_NAME = 'ChatDB';
const STORE_NAME = 'pendingMessages';

export async function openDB() {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'localId' });
      }
    };
  });
}

export async function savePendingMessage(message: Message) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  tx.objectStore(STORE_NAME).add(message);
}

export async function removePendingMessage(localId: string) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  tx.objectStore(STORE_NAME).delete(localId);
}

export async function getAllPendingMessages(): Promise<Message[]> {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readonly');
  const store = tx.objectStore(STORE_NAME);

  return new Promise((resolve) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
  });
}

Experiencia práctica

  1. No busques la perfección al inicio: implementa envío/recepción básico y luego añade estados.
  2. Prioriza los fallos de envío: al usuario le importa más si el mensaje salió; “leído” es secundario.
  3. IndexedDB salva: con mala red, el almacenamiento local ayuda mucho.
  4. Prueba escenarios sin red: en Chrome DevTools → Network puedes simular Offline; prueba varias veces.

Despliegue en producción y optimización de rendimiento

Todo funciona en desarrollo y falla al publicar — lo habitual en comunicación en tiempo real. Resumo puntos clave para evitar tropiezos.

Elección de plataforma y limitaciones

Ya comentamos que Vercel no admite WebSocket. Detalle por plataforma:

PlataformaWebSocketSSELimitaciones especiales
VercelEdge: timeout 25 s; Serverless: timeout 60 s
NetlifyFunction timeout 10 s
RailwaySin timeout fijo, facturación por tráfico
RenderPlan gratuito con sleep
Cloudflare PagesWorkers con límite de tiempo CPU
VPS autoalojadoGestión propia del servidor

Recomendación:

  • Presupuesto ajustado + baja concurrencia: Vercel + SSE (cuota gratuita amplia)
  • Necesitas WebSocket: Railway o Render ($5-10/mes)
  • Alta concurrencia + presupuesto: VPS propio + proxy inverso Nginx

Optimización de SSE en Vercel

Edge Function con timeout de 25 segundos. Mi solución: reconexión automática + heartbeat:

// hooks/useSSE.ts
'use client';

import { useEffect, useRef, useState } from 'react';

export function useSSE(url: string) {
  const [isConnected, setIsConnected] = useState(false);
  const eventSourceRef = useRef<EventSource | null>(null);
  const reconnectTimeoutRef = useRef<NodeJS.Timeout>();

  const connect = () => {
    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      console.log('SSE conectado');
      setIsConnected(true);
    };

    eventSource.onerror = () => {
      console.error('Error SSE, reconectando en 3 segundos...');
      setIsConnected(false);
      eventSource.close();

      // Reconexión automática
      reconnectTimeoutRef.current = setTimeout(() => {
        connect();
      }, 3000);
    };

    return eventSource;
  };

  useEffect(() => {
    const eventSource = connect();

    return () => {
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current);
      }
      eventSource.close();
    };
  }, [url]);

  return { eventSource: eventSourceRef.current, isConnected };
}

Sincronización de mensajes multi-instancia

Vercel crea varias instancias. El usuario A en la instancia 1 y el B en la 2 — ¿cómo se envían mensajes?

Respuesta: Redis Pub/Sub.

Con Upstash (Redis serverless) implementé este esquema:

// lib/redis.ts
import { Redis } from '@upstash/redis';

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!
});

// Publicar mensaje en Redis
export async function publishMessage(channel: string, message: any) {
  await redis.lpush(channel, JSON.stringify(message));
  await redis.ltrim(channel, 0, 99); // Solo los 100 más recientes
}

// Obtener historial de mensajes
export async function getRecentMessages(channel: string) {
  const messages = await redis.lrange(channel, 0, -1);
  return messages.map((m) => JSON.parse(m as string)).reverse();
}

Lista de optimización de rendimiento

  1. Deduplicación: el cliente puede recibir duplicados; usa Set para IDs ya procesados.

  2. Scroll virtual: con más de 100 mensajes, usa react-window o react-virtualized.

  3. Carga perezosa del historial: no cargues todo de golpe; pagina al hacer scroll arriba.

  4. Protección con rate limiting: limita envíos en cliente y servidor.

// Rate limiting simple en el cliente
let lastSendTime = 0;
const SEND_INTERVAL = 500; // Un mensaje cada 500 ms como máximo

const sendMessage = async (content: string) => {
  const now = Date.now();
  if (now - lastSendTime < SEND_INTERVAL) {
    alert('Envías demasiado rápido, espera un momento');
    return;
  }

  lastSendTime = now;
  // ... lógica de envío
};
  1. Monitorización y logs: Sentry o LogRocket para fallos de SSE y envío de mensajes.

Control de costes

Las funciones en tiempo real pueden ser caras, sobre todo con WebSocket. Formas de ahorrar:

  • SSE en lugar de WebSocket: evitas servidor WebSocket dedicado
  • Agrupar push: no un push por mensaje; batch cada segundo
  • Upstash para Redis: facturación por petición, más barato que Redis propio
  • CDN para estáticos: recursos estáticos de Next.js en CDN para aligerar el servidor

Gestioné una sala con 500 usuarios en línea con Vercel + Upstash por menos de $15 al mes. La clave es elegir bien el esquema.

Resumen

Volviendo al desastre de madrugada del inicio — si hubiera sabido esto, no habría insistido con WebSocket.

La comunicación en tiempo real en Next.js se resume en elegir con pragmatismo:

  • ¿Despliegue en Vercel? Usa SSE; no fuerces WebSocket
  • ¿Presupuesto limitado? Empieza con lo más simple; no acumules tecnología al inicio
  • ¿Experiencia de usuario? Estados de mensaje y reconexión importan más que el brillo técnico

Puedes usar el código de este artículo directamente, pero lo importante es entender los trade-offs. No hay bala de plata; lo que encaje en tu proyecto es lo mejor.

Si también estás construyendo funciones en tiempo real, espero que esto te ahorre rodeos. Comenta si tienes dudas; intentaré responder.

Próximos pasos:

  1. Ejecuta en local el ejemplo SSE más simple
  2. Despliega en Vercel y prueba
  3. Si necesitas WebSocket, valora Railway o autoalojamiento

La deuda técnica no asusta; lo que asusta es cargar deuda innecesaria desde el principio. ¡Ánimo!

FAQ

¿Por qué Vercel no admite WebSocket?
Vercel usa arquitectura Serverless: la aplicación corre en funciones en la nube. Estas funciones liberan recursos en cuanto termina la petición, por lo que no pueden mantener la conexión persistente que requiere WebSocket.

Hay tres soluciones:
• Usar SSE (Server-Sent Events) como alternativa, con soporte nativo en Vercel
• Alquilar un servidor independiente (VPS) o usar plataformas que admitan WebSocket (Railway, Render)
• Usar servicios WebSocket de terceros (Pusher, Ably), aunque el coste es mayor ($49-99/mes)

Para la mayoría de escenarios, SSE es suficiente y más económico.
¿Qué es más adecuado para una app de chat, SSE o WebSocket?
Depende de tu plataforma de despliegue y de tus necesidades:

• SSE es adecuado para: escenarios con push unidireccional predominante (notificaciones en tiempo real, recepción de mensajes en salas de chat), despliegue en plataformas Serverless como Vercel/Netlify, menos de 1000 usuarios
• WebSocket es adecuado para: comunicación bidireccional de alta frecuencia (edición colaborativa, juegos online), servidor autoalojado, baja latencia

Las apps de chat suelen funcionar bien con SSE (recibir mensajes) + HTTP POST (enviar mensajes): menor coste y despliegue más simple. He gestionado una sala con 500 usuarios en línea con SSE + Upstash Redis por menos de $15 al mes.
¿Cómo manejar el límite de 25 segundos de timeout en Vercel?
Las Edge Functions de Vercel tienen un límite de 25 segundos. La solución es la reconexión automática en el cliente:

• El servidor envía heartbeats: uno cada 15 segundos para evitar que la conexión se considere inactiva
• El cliente detecta desconexiones: escucha el evento onerror de EventSource
• Mecanismo de reconexión automática: reconectar 3 segundos después de detectar la desconexión
• Redis almacena mensajes: garantiza que tras reconectar se obtengan los mensajes no leídos

En la práctica, el usuario casi no nota el proceso de reconexión; la experiencia es similar a una conexión persistente.
¿Cómo sincronizar mensajes con despliegue multi-instancia?
Vercel crea automáticamente varias instancias; distintos usuarios pueden conectarse a instancias diferentes. La solución es Redis Pub/Sub:

• El usuario A envía un mensaje → la instancia 1 lo escribe en Redis
• Las instancias 1, 2 y 3 consultan Redis para obtener mensajes nuevos
• Cada instancia hace push del mensaje a sus usuarios conectados

Se recomienda Upstash (Redis serverless), facturado por petición, más barato que un Redis propio. Intervalo de polling de 1 segundo para equilibrar tiempo real y coste.
¿Cómo garantizar que no se pierdan mensajes?
Los mensajes pueden perderse por problemas de red. La solución completa incluye:

• Actualización optimista: mostrar al instante en la interfaz antes de enviar, con estado "enviando"
• Almacenamiento local con IndexedDB: guardar mensajes no confirmados en la base de datos local
• Confirmación del servidor: tras respuesta exitosa, actualizar el estado a "enviado"
• Recuperación tras reconexión: leer mensajes no confirmados de IndexedDB y reenviarlos automáticamente
• Mecanismo de reintento: mensajes fallidos muestran botón de reenvío para reintento manual

Inspirado en el diseño de estados de mensaje de WeChat: enviando, enviado, entregado y fallido.
¿Se puede desplegar Socket.io Custom Server en Vercel?
No. Custom Server requiere un proceso Node.js en ejecución continua, y Vercel solo admite funciones Serverless.

Alternativas:
• Plataformas con soporte WebSocket: Railway (desde $5/mes), Render (desde $7/mes)
• VPS autoalojado: Vultr, DigitalOcean (desde $5/mes)
• Despliegue híbrido: app Next.js en Vercel, servicio WebSocket desplegado por separado

Si debes usar Vercel, conviene cambiar a SSE; funcionalmente cubre la mayoría de necesidades de comunicación en tiempo real.
¿Dónde están los cuellos de botella de rendimiento en una app de chat en tiempo real?
Cuellos de botella habituales y soluciones:

• Renderizado de lista de mensajes: con más de 100 mensajes, usar scroll virtual (react-window)
• Carga de historial: carga perezosa, paginación al hacer scroll hacia arriba
• Limitación en cliente: solo un mensaje cada 500 ms para evitar spam malicioso
• Limitación en servidor: rate limiting en rutas API
• Deduplicación: usar Set para registrar IDs de mensajes recibidos y evitar renderizado duplicado
• Límite de conexiones: HTTP/1.1 permite máximo 6 conexiones por dominio; usar HTTP/2 o detección de pestaña única

Herramientas de monitorización recomendadas: Sentry (seguimiento de errores), LogRocket (reproducción de sesiones), Vercel Analytics (monitorización de rendimiento).

18 min de lectura · Publicado el: 7 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog