Self-Hosting vs Serverless, WebSockets, and Long-Lived Connections22 min read
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.
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.
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:
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.
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.
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';
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.
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';
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 (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); }, });
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.
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.
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 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.
The solution: Redis-backed cache handler (also from A-3), or disable ISR's filesystem cache entirely and use use cache with a remote cache instead.
output: 'export' is the setting that gives you a fully static HTML export — a folder of .html, .js, and .css files you can drop on S3, GitHub Pages, or any dumb file host with no Node.js server running at request time. It's the right call for a marketing site or docs site that doesn't need per-request logic.
The trap: a large chunk of "Next.js features" documented everywhere else in this course quietly stop working the moment you flip this on, and several of them fail silently rather than throwing a build error. You ship, the exported site looks fine, and three weeks later someone notices the locale switcher does nothing.
This is the quick-reference graveyard — what dies, and how it dies:
Feature
What happens with output: 'export'
Route Handlers
Only handlers exporting a static GET (no dynamic behavior, no reading request-specific data) are allowed. Anything else fails the build with an explicit error — this one at least fails loudly.
Middleware
Not supported at all. If you have a middleware.ts, either remove it or don't use static export — there's no server in front of the static files to run it.
Server Actions
Not supported. 'use server' functions require a server to invoke; a static export has none. Build fails if you try.
ISR (revalidate on fetch or route segment config)
Silently ignored. Every page is rendered once at build time and never again — there's no server to re-render it later. The revalidate: 60 you set doesn't error, it just does nothing.
Image Optimization API (next/image)
The default loader (which calls the /_next/image optimization endpoint) has no server to call. You must configure a custom loader that points at a third-party image CDN, or set images.unoptimized: true and ship full-size images as-is.
Automatic i18n locale detection/routing
The i18n config's automatic Accept-Language detection and locale-prefixed routing that Next.js normally handles server-side isn't available. Only manual sub-path routing (where you build a separate static tree per locale yourself) works.
cookies() / headers() (dynamic reads)
These APIs assume a per-request server context that doesn't exist. Calling them in a component that's part of the static export will error at build time (or, if used in a way the type system can't catch, produce a build-time exception) — verify the exact failure mode against your Next.js version, since this has changed across releases.
Draft Mode
Not supported — it depends on a Route Handler setting a cookie and the server reading it back on subsequent requests, both of which require a running server.
The pattern worth internalizing: anything that depends on this specific request — reading a cookie, checking a header, running code on revalidation, invoking Middleware — has no home in a build artifact that's just files on disk. If your app needs any of that, output: 'export' isn't a deployment optimization, it's the wrong output mode, and moving to a serverless or self-hosted Node.js server (drop the output: 'export' line) is the fix, not working around it.
The comparison table later in this module lists cold starts as "Present (mitigatable)" for serverless. Here's the actual playbook — and the honest framing: none of this eliminates cold starts. Serverless functions get torn down when idle and reprovisioned on the next request; that provisioning time is inherent to the model. What follows reduces how often you pay it and how much it costs when you do.
1. Keep the function bundle small. Every serverless function is a bundle — your route's code plus everything it imports, tree-shaken but still bundled. A route that imports a full ORM client, a large validation schema library, and an SDK for a third-party service it barely touches produces a bundle that has to be downloaded and initialized before your code runs a single line. Trim unused imports, prefer lighter alternatives for hot-path routes, and avoid barrel-file imports (import { thing } from '@/lib') that accidentally pull in everything the barrel re-exports even when you only need one thing.
// Bad: pulls in the entire lib barrel, and everything it re-exports import { formatCurrency } from '@/lib';
// Better: imports only what this function actually needs import { formatCurrency } from '@/lib/currency';
2. Use the Edge runtime where the code allows it. Edge runtime functions (built on a stripped-down V8 isolate model rather than a full Node.js process) generally cold-start faster than Node.js serverless functions, because there's less runtime to spin up. The catch: Edge runtime doesn't support the full Node.js API surface — no fs, no most native Node modules, and many database drivers that rely on raw TCP sockets don't work there. It's a good fit for Middleware, simple auth checks, and routes that only need fetch and basic JS. Verify against your specific dependencies before moving a route to Edge — a database client that needs a raw TCP connection (rather than an HTTP-based driver) will simply fail on Edge, not degrade gracefully.
// app/api/geo/route.ts export const runtime = 'edge'; // opt into the faster-cold-starting runtime
export async function GET(request: Request) { const country = request.headers.get('x-vercel-ip-country'); return Response.json({ country }); }
3. Don't do heavy work at module-evaluation time. Code at the top level of a file that ends up in a serverless bundle runs once per cold start, before the first request is handled — not lazily on first use unless you write it that way. Eagerly connecting to a database, reading and parsing a large config file, or doing expensive computation at import time adds directly to cold start latency, even for requests that don't need that work yet.
// Bad: connects at module load — every cold start pays this cost upfront, // even for requests that never touch the database const db = await connectToDatabase();
export async function GET() { return Response.json(await db.query('...')); }
// Better: lazy singleton — connection cost is paid once, on first actual use, // and reused across warm invocations (same pattern as the Prisma globalThis // singleton from P-5, applied to cold-start sensitivity specifically) let dbPromise: ReturnType<typeof connectToDatabase> | null = null;
function getDb() { if (!dbPromise) dbPromise = connectToDatabase(); return dbPromise; }
export async function GET() { const db = await getDb(); return Response.json(await db.query('...')); }
4. For latency-sensitive routes that can't tolerate any cold start, consider keeping functions warm. On Vercel, this generally means either provisioned concurrency-style configurations (verify current naming and availability against your plan — Vercel's offerings here have shifted over time) or a scheduled job that pings the route on an interval to keep at least one instance warm. Both cost real money — you're paying for idle compute to avoid latency, which is the opposite of serverless's usual value proposition. This is a trade-off for a specific subset of routes (payment checkout, a real-time dashboard's initial load) — not something to apply blanket across an application.
Be clear-eyed about the ceiling here: even with all four of these applied, the first request to a genuinely cold function still pays some provisioning penalty. The playbook shrinks the frequency (fewer cold starts, because functions can serve more requests before scaling to zero) and the size (a smaller bundle and lazy initialization mean the cold start itself is shorter) — it doesn't make serverless behave like an always-on process. If a route truly cannot tolerate any cold start under any traffic pattern, that's a signal it may belong on always-on infrastructure (self-hosted, or a keep-warm strategy budgeted as a real cost) rather than a serverless function you're trying to optimize around the grain of the model.
The practical decision: start with Vercel. Migrate specific services (WebSockets, background processing) to dedicated always-on infrastructure when you hit the serverless constraints. Only migrate the entire Next.js application to Kubernetes if you have specific reasons — usually cost at very high volume or regulatory requirements around where compute runs.
A-15 covers production observability: the tracing, metrics, logging, and alerting architecture that lets you understand what your application is doing in production, diagnose incidents quickly, and catch regressions before users report them.
The deployment topology for WebSockets in production (covered earlier in this module) is clear. The authentication story is not — and it fails in ways that are hard to debug because they're browser-specific and often silent.
Your application authenticates via HttpOnly cookies. The browser sends cookies on every HTTP request automatically. When the browser opens a WebSocket connection, it sends an HTTP GET with an Upgrade: websocket header. Cookies are included in this upgrade request — so far so good.
The problem: the HTTP 101 Switching Protocols response establishes the WebSocket connection. After that, there's no more HTTP. The WebSocket protocol has no built-in mechanism to re-send cookies on subsequent messages. The authentication happens once, at connection time.
What this means operationally:
If the session cookie expires while the socket is open, the server has no way to know until the client makes a new HTTP request. The WebSocket connection stays open with an expired session.
If the user logs out (session cookie is deleted), the WebSocket connection stays open. The client keeps receiving real-time updates for a user who has logged out.
Some environments (certain mobile browsers, proxy servers, load balancers) strip cookies from the WebSocket upgrade request. The connection goes through but arrives unauthenticated.
The most reliable approach: don't rely on the upgrade request for authentication. Complete the HTTP handshake, then require the client to send an auth message as the first message over the socket.
// server/websocket-server.ts (separate Node.js service) import { WebSocketServer, WebSocket } from 'ws' import { verifyToken } from './lib/auth'
const wss = new WebSocketServer({ port: 3001 })
wss.on('connection', (ws) => { let userId: string | null = null let authTimeout: ReturnType<typeof setTimeout>
// Require authentication within 5 seconds authTimeout = setTimeout(() => { if (!userId) { ws.close(4001, 'Authentication timeout') } }, 5000)
ws.on('close', () => { clearTimeout(authTimeout) // Clean up any subscriptions for this userId }) })
The client sends a short-lived access token (not the HttpOnly session cookie, which isn't accessible to JavaScript). Generate this token specifically for WebSocket authentication:
// app/api/ws-token/route.ts — Route Handler that issues a short-lived WS token export async function GET() { const session = await auth() if (!session) return new Response('Unauthorized', { status: 401 })
// Client-side WebSocket connection async function connectWebSocket() { // Get a short-lived token from your server const { token } = await fetch('/api/ws-token').then(r => r.json())
WebSocket connections drop. Networks change. Mobile devices sleep. Your client reconnects every time. Each reconnect goes through the same auth flow: fetch a new short-lived token, send it as the first message.
The short-lived token model (60s expiry) ensures that reconnections always use a fresh token — no stale token can be reused from a previous connection session.
// Client reconnect logic with exponential backoff function createReconnectingWebSocket(url: string) { let ws: WebSocket | null = null let reconnectDelay = 1000 let shouldReconnect = true
async function connect() { // Always fetch a fresh token before reconnecting const { token } = await fetch('/api/ws-token').then(r => r.json())
This is the scenario that causes the most pain: you need to rotate your JWT signing secret. On HTTP, it's straightforward — new tokens use the new secret, old tokens with the old secret expire naturally within the token TTL. No active user is affected.
On WebSockets, it's different. You have 40,000 active connections. Each was authenticated with a token signed with the old secret. If you delete the old secret, you cannot validate those tokens. All 40,000 connections become effectively unauthenticated on their next message.
The graceful rotation protocol:
Step 1: Add the new secret while keeping the old one. Token validation accepts either.
// lib/auth.ts const JWT_SECRETS = [ process.env.JWT_SECRET_NEW!, // primary — used for signing process.env.JWT_SECRET_OLD!, // secondary — accepted for validation only ]
export async function verifyToken(token: string) { for (const secret of JWT_SECRETS) { try { return await jwtVerify(token, new TextEncoder().encode(secret)) } catch { continue } } throw new Error('Invalid token') }
Step 2: Issue a system-wide re-authentication request over the WebSocket itself.
// Send to all connected clients wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'reauth_required', reason: 'secret_rotation', deadline: Date.now() + 60000, // 60 seconds to re-authenticate })) } })
Step 3: Clients receive reauth_required, fetch a new short-lived token (signed with the new secret), and send it as an auth message. The connection continues uninterrupted for clients that re-authenticate.
Step 4: After the deadline, close connections that haven't re-authenticated.
// After 60 seconds wss.clients.forEach((client) => { const state = connectionState.get(client) if (state?.tokenSignedWithOldSecret) { client.close(4004, 'Re-authentication required') } })
Step 5: Remove the old secret from JWT_SECRETS. Deploy.
This protocol handles secret rotation with zero forced disconnections for clients that are online during the rotation window. Clients that are offline (mobile with bad network) reconnect normally after the rotation — they fetch a new token signed with the new secret.
Without per-user connection limits, a single user (or a compromised account) can open thousands of connections and exhaust your server's file descriptor limit.
Knowledge Check
Why are serverless functions (like Vercel Functions or AWS Lambda) generally unsuitable for hosting WebSocket servers?
What is the primary issue with relying on the HTTP Upgrade request for authenticating WebSocket connections?
In a self-hosted Kubernetes deployment of Next.js, how does the behavior of the Prisma globalThis singleton pattern differ from a serverless deployment?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.