Module A-14·22 min read

Standalone output internals, static export feature graveyard (what silently breaks), WebSocket authentication (HttpOnly cookie on upgrade handshake, token validation on reconnect, secret rotation with 40K live connections), Pusher/Ably vs Partykit vs self-hosted socket layer, and the cold start optimisation playbook.

Written by Jatin Jain Saraf · Architect of SupraScan

A-14 — Self-Hosting vs Serverless, WebSockets, and Long-Lived Connections

Who this is for: Architects making infrastructure decisions for Next.js applications that have outgrown the "just deploy to Vercel" answer — teams self-hosting on Kubernetes, applications that need WebSockets or Server-Sent Events, and anyone who needs to understand the real constraints that serverless imposes on connection-oriented features.


The Fundamental Serverless Constraint

Serverless (Vercel Functions, AWS Lambda, Cloudflare Workers) is the dominant deployment model for Next.js because it matches the request-per-invocation model well. A request comes in, the function runs, the response goes out. For stateless HTTP, this is ideal.

The constraint: serverless functions don't persist between requests. Each invocation may run in a new container. There's no in-process state, no long-lived connections, no background threads.

This constraint rules out a specific category of features:

  • WebSocket servers (require persistent TCP connections)
  • Server-Sent Events that outlive a response (require persistent HTTP connections)
  • In-memory job queues (state lost on restart)
  • Background processing beyond the request duration
  • In-process caches shared across requests (each instance has its own memory)

These are not framework limitations — they're infrastructure physics. The question is which of these your application actually needs, and what the alternatives are.


WebSockets in a Next.js Application

Next.js doesn't have built-in WebSocket support. The Route Handler model (request → response) doesn't map to WebSockets (persistent bidirectional connection). This is not a missing feature — it's an architectural mismatch.

The three architectural patterns for WebSockets alongside Next.js:

Pattern 1: Separate WebSocket server

The most common production pattern. A dedicated Node.js service (Socket.io, ws, uWebSockets.js) handles WebSocket connections. The Next.js application communicates with it via HTTP or a message broker.

Browser
  ├── HTTP requests → Next.js (Vercel/serverless)
  └── WebSocket → ws.example.com (always-on Node.js service)
// components/ChatRoom.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
export function ChatRoom({ roomId }: { roomId: string }) {
  const wsRef = useRef<WebSocket | null>(null);
 
  useEffect(() => {
    const ws = new WebSocket(`wss://ws.example.com/room/${roomId}`);
    wsRef.current = ws;
 
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      // update state
    };
 
    return () => ws.close();
  }, [roomId]);
 
  return <div>...</div>;
}

Pattern 2: Partykit / Cloudflare Durable Objects

Cloudflare Durable Objects are stateful edge workers — they persist state and accept WebSocket connections. Partykit is a higher-level abstraction built on Durable Objects.

// party/chat.ts (Partykit server)
import type * as Party from 'partykit/server';
 
export default class ChatRoom implements Party.Server {
  messages: string[] = [];
 
  onConnect(conn: Party.Connection) {
    conn.send(JSON.stringify({ type: 'history', messages: this.messages }));
  }
 
  onMessage(message: string) {
    this.messages.push(message);
    this.party.broadcast(message); // send to all connected clients
  }
}

This runs entirely at the edge — WebSocket connections go to the nearest Cloudflare datacenter, not a central origin server. For real-time applications with global users, this is a compelling architecture.

Pattern 3: Serverless WebSockets (Pusher, Ably, Soketi)

Managed WebSocket infrastructure as a service. Your Next.js application publishes events to the service via HTTP. The service maintains WebSocket connections to clients and delivers events.

// Server Action publishes an event
'use server';
import Pusher from 'pusher';
 
const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.PUSHER_CLUSTER!,
});
 
export async function sendMessage(roomId: string, message: string) {
  await pusher.trigger(`room-${roomId}`, 'new-message', { message });
}
// Client subscribes via Pusher JS SDK
'use client';
import Pusher from 'pusher-js';
 
useEffect(() => {
  const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
    cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  });
 
  const channel = pusher.subscribe(`room-${roomId}`);
  channel.bind('new-message', (data: { message: string }) => {
    // handle message
  });
 
  return () => pusher.disconnect();
}, [roomId]);

The trade-off: operational simplicity (no WebSocket server to manage) vs. cost (per-message pricing at scale) and latency (extra hop through the managed service).


Server-Sent Events

Server-Sent Events (SSE) are a lighter-weight alternative to WebSockets for one-directional server-to-client streaming. They use regular HTTP, work through most proxies, and automatically reconnect.

In Next.js Route Handlers, SSE works on self-hosted deployments but not on Vercel Functions — Vercel Functions have a maximum response duration, and SSE requires a persistent connection:

// app/api/events/route.ts
export async function GET() {
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();
 
      // Send an event every second
      const interval = setInterval(() => {
        const data = `data: ${JSON.stringify({ timestamp: Date.now() })}\n\n`;
        controller.enqueue(encoder.encode(data));
      }, 1000);
 
      // Clean up on client disconnect
      return () => clearInterval(interval);
    },
  });
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

On Vercel, SSE responses are limited to Vercel's streaming response duration limit. For indefinite streams, you need self-hosted or a managed SSE service.


Self-Hosting on Kubernetes

For teams that need persistent connections, long-lived processes, or full infrastructure control, Kubernetes is the production target.

The canonical self-hosted architecture:

Kubernetes Cluster
├── Next.js Deployment (3-5 replicas)
│   ├── Port 3000 — HTTP server (standalone build)
│   └── Horizontal Pod Autoscaler — scale on CPU/request rate
├── WebSocket Service (separate Deployment)
│   ├── Port 8080 — WebSocket connections
│   └── Sticky sessions (for stateful connections)
├── Redis (shared cache, session store)
├── PostgreSQL (or RDS, Cloud SQL)
└── Ingress (nginx or Traefik)
    ├── / → Next.js service
    └── /ws → WebSocket service

The key difference from serverless: pods persist between requests. In-process state (Prisma connection pools, warm Module singletons) survives across requests. This is why the Prisma globalThis singleton pattern (P-5) matters — on Kubernetes, the singleton is reused across thousands of requests per pod. On serverless, the singleton is created anew each cold start.

Kubernetes deployment manifest (simplified):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nextjs-app
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: nextjs
          image: my-app:latest
          ports:
            - containerPort: 3000
          env:
            - name: NODE_ENV
              value: production
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: database-url
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 30
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000

The livenessProbe and readinessProbe point to the health check Route Handler from P-14. Kubernetes uses these to determine which pods should receive traffic and whether to restart unhealthy pods.


ISR on Self-Hosted Kubernetes

ISR on Kubernetes has the distributed cache invalidation problem from A-3 — each pod has its own filesystem cache. revalidatePath('/products') on Pod A doesn't invalidate Pod B.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.