Self-Hosting vs Serverless, WebSockets, and Long-Lived Connections27 min read
Module A-14·27 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.
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:
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.
text
tsx
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.
ts
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.
ts
tsx
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:
ts
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:
text
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):
yaml
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.
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.
ts
With a Redis cache handler, all pods share the same cache. Revalidation from any pod propagates to all pods via Redis.
The Static Export Feature Graveyard
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.
Cold Start Optimisation Playbook
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.
ts
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.
ts
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.
ts
ts
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.
Choosing the Right Deployment Model
Requirement
Serverless (Vercel)
Self-hosted (K8s)
Zero infrastructure ops
✅
❌
WebSockets
❌ (use separate service)
✅
Long-running background jobs
❌
✅
Per-request isolation
✅
✅ (separate pods)
Global edge distribution
✅ (built-in)
✅ (needs CDN setup)
ISR with multiple instances
✅ (Vercel handles it)
Requires Redis handler
Predictable cost at high volume
❌ (per-request pricing)
✅ (fixed pod cost)
Cold starts
Present (mitigatable)
None
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.
Where We Go From Here
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.
WebSocket Authentication — The Part Nobody Documents
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.
The HttpOnly Cookie Problem on Upgrade
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.
Authentication Pattern: Token in Initial Message
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.
ts
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:
ts
ts
Token Validation on Reconnect
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.
ts
JWT Secret Rotation with Live Connections
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.
ts
Step 2: Issue a system-wide re-authentication request over the WebSocket itself.
ts
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.
ts
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.
Per-Connection Rate Limiting
One thing the WebSocket authentication story often misses: rate limiting at the connection and message level.
ts
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.
// app/api/events/route.tsexportasyncfunctionGET(){const stream =newReadableStream({start(controller){const encoder =newTextEncoder();// Send an event every secondconst interval =setInterval(()=>{const data =`data: ${JSON.stringify({ timestamp: Date.now()})}\n\n`; controller.enqueue(encoder.encode(data));},1000);// Clean up on client disconnectreturn()=>clearInterval(interval);},});returnnewResponse(stream,{ headers:{'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive',},});}
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
// Bad: pulls in the entire lib barrel, and everything it re-exportsimport{ formatCurrency }from'@/lib';// Better: imports only what this function actually needsimport{ formatCurrency }from'@/lib/currency';
// app/api/geo/route.tsexportconst runtime ='edge';// opt into the faster-cold-starting runtimeexportasyncfunctionGET(request: Request){const country = request.headers.get('x-vercel-ip-country');return Response.json({ country });}
// Bad: connects at module load — every cold start pays this cost upfront,// even for requests that never touch the databaseconst db =awaitconnectToDatabase();exportasyncfunctionGET(){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;functiongetDb(){if(!dbPromise) dbPromise =connectToDatabase();return dbPromise;}exportasyncfunctionGET(){const db =awaitgetDb();return Response.json(await db.query('...'));}
// server/websocket-server.ts (separate Node.js service)import{ WebSocketServer, WebSocket }from'ws'import{ verifyToken }from'./lib/auth'const wss =newWebSocketServer({ port:3001})wss.on('connection',(ws)=>{let userId:string|null=nulllet authTimeout: ReturnType<typeof setTimeout>// Require authentication within 5 seconds authTimeout =setTimeout(()=>{if(!userId){ ws.close(4001,'Authentication timeout')}},5000) ws.on('message',async(data)=>{const message =JSON.parse(data.toString())// First message must be authif(!userId){if(message.type !=='auth'){ ws.close(4002,'First message must be auth')return}try{const payload =awaitverifyToken(message.token) userId = payload.sub
clearTimeout(authTimeout) ws.send(JSON.stringify({ type:'auth_ok', userId }))}catch{ ws.close(4003,'Invalid token')}return}// Handle authenticated messageshandleMessage(ws, userId, message)}) ws.on('close',()=>{clearTimeout(authTimeout)// Clean up any subscriptions for this userId})})
// app/api/ws-token/route.ts — Route Handler that issues a short-lived WS tokenexportasyncfunctionGET(){const session =awaitauth()if(!session)returnnewResponse('Unauthorized',{ status:401})// Short-lived token — 60 seconds, enough to complete the WS handshakeconst wsToken =awaitsignJWT({ sub: session.user.id, scope:'websocket'},{ expiresIn:'60s'})return Response.json({ token: wsToken })}
// Client-side WebSocket connectionasyncfunctionconnectWebSocket(){// Get a short-lived token from your serverconst{ token }=awaitfetch('/api/ws-token').then(r => r.json())const ws =newWebSocket('wss://ws.yourapp.com') ws.onopen=()=>{// First message: authenticate ws.send(JSON.stringify({ type:'auth', token }))} ws.onmessage=(event)=>{const msg =JSON.parse(event.data)if(msg.type ==='auth_ok'){// Now we can start sending/receiving application messagessubscribeToUpdates(ws)}}}
// Client reconnect logic with exponential backofffunctioncreateReconnectingWebSocket(url:string){let ws: WebSocket |null=nulllet reconnectDelay =1000let shouldReconnect =trueasyncfunctionconnect(){// Always fetch a fresh token before reconnectingconst{ token }=awaitfetch('/api/ws-token').then(r => r.json()) ws =newWebSocket(url) ws.onopen=()=>{ reconnectDelay =1000// reset backoff on successful connection ws!.send(JSON.stringify({ type:'auth', token }))} ws.onclose=(event)=>{if(!shouldReconnect)returnif(event.code ===4003){// Invalid token — likely session expired, redirect to login window.location.href ='/login'return}setTimeout(connect, reconnectDelay) reconnectDelay = Math.min(reconnectDelay *2,30000)// cap at 30s}}connect()return{disconnect:()=>{ shouldReconnect =false ws?.close()},}}
// lib/auth.tsconstJWT_SECRETS=[ process.env.JWT_SECRET_NEW!,// primary — used for signing process.env.JWT_SECRET_OLD!,// secondary — accepted for validation only]exportasyncfunctionverifyToken(token:string){for(const secret ofJWT_SECRETS){try{returnawaitjwtVerify(token,newTextEncoder().encode(secret))}catch{continue}}thrownewError('Invalid token')}
// Send to all connected clientswss.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}))}})
// After 60 secondswss.clients.forEach((client)=>{const state = connectionState.get(client)if(state?.tokenSignedWithOldSecret){ client.close(4004,'Re-authentication required')}})