Back to Blog

NestJS WebSocket Not Working on Vercel — The Real Fix

Published: July 27, 2026
NestJS WebSocket Not Working on Vercel — The Real Fix

If your NestJS WebSocket is not working on Vercel, I can save you a weekend of staring at your gateway decorators: it isn't your code. Vercel's serverless functions are stateless — they spin up, handle one request-response cycle, and get frozen or torn down, with no guarantee the same instance survives to serve your next message. A WebSocket needs the opposite of that: one process, staying alive, holding the connection open for as long as the client is around. You cannot configure your way out of that mismatch. You can only move the gateway somewhere that isn't Vercel's function runtime.

Short answer, for anyone who found this mid-incident: host your NestJS WebSocket gateway on an always-on service (Railway, Render, a small VM) or hand realtime delivery to a managed provider (Ably, Pusher, Supabase Realtime), and keep the rest of your NestJS app on Vercel if you want. The two don't have to live in the same place. Below is the full "why," how to confirm it's actually this and not something dumber, and the two ways teams fix it in production.

Close-up of a developer debugging a NestJS WebSocket gateway on a laptop

Why NestJS WebSocket Not Working on Vercel Isn't a Bug in Your Gateway

Every @WebSocketGateway() decorator you've written is fine. The problem sits one layer down, in how Vercel executes the function your gateway lives inside. Vercel's own documentation is blunt about this: serverless functions don't support persistent WebSocket connections, full stop — not with a workaround flag, not with a bigger plan, not with a longer maxDuration.

Here's the mechanism, because "it just doesn't work" isn't satisfying at 11pm: a WebSocket connection starts life as a normal HTTP request that gets upgraded to a persistent, bidirectional TCP connection. Vercel's function runtime is built around request-in, response-out, then the instance is free to be frozen, reused for something else, or killed. There's no commitment that the process handling your handleConnection() callback is still the process that's supposed to be listening five seconds later. Sometimes the handshake technically completes and the socket drops on the very next message. Sometimes it never upgrades at all. Either way, you're not fighting a bug — you're fighting the platform's execution model, and the platform wins every time.

(If you've been adding retry logic to your Socket.io client config hoping it'll "just reconnect and work" — it will reconnect, straight into the same wall, forever. Ask me how I know.)

How to Confirm This Is Actually Your Problem

Before you tear anything down, spend five minutes confirming this is the real cause and not something dumber you can fix in one line:

  1. Check it locally first. If the gateway works on localhost and only breaks once deployed to Vercel, that's the signature — a code bug would usually show up locally too.
  2. Look at the actual handshake response, not just "it disconnected." A 101 Switching Protocols that never arrives, or one that arrives and then the connection closes within a second or two, both point at the platform, not your logic.
  3. Check your CORS and adapter config anyway. A misconfigured cors option on the gateway or a missing adapter can look similar from the client side. Rule it out so you're not solving the wrong problem.
  4. Check Vercel's function logs during a connection attempt. If you see the function invoked, returning cleanly, and then nothing — that's consistent with the function completing and the connection being severed underneath it.

If all four point the same direction, stop debugging your gateway. It was never going to work here, and no amount of decorator-tweaking changes that.

Option 1: Move the Gateway to an Always-On Service

The most direct fix: run the WebSocket gateway on a host that keeps one process alive, and leave everything else — your REST API, your static frontend — wherever it already is. Railway, Render, Fly.io, or a small VM all work, because none of them recycle your process between requests the way Vercel does.

TypeScript
1// gateway-service/src/main.ts — a small, standalone NestJS app
2// that does nothing except host the WebSocket gateway
3import { NestFactory } from '@nestjs/core';
4import { AppModule } from './app.module';
5
6async function bootstrap() {
7  const app = await NestFactory.create(AppModule);
8  app.enableCors({ origin: process.env.FRONTEND_URL, credentials: true });
9
10  // Railway/Render inject their own PORT — don't hardcode 3000
11  await app.listen(process.env.PORT ?? 3000);
12}
13bootstrap();

Your gateway class doesn't change at all — @WebSocketGateway(), @SubscribeMessage(), the Redis adapter for multi-instance fan-out, none of it cares where the process runs. What changes is deployment: this becomes its own small service with its own URL, and your Next.js frontend on Vercel connects to that URL instead of trying to reach a gateway baked into the same serverless deployment. If you're also running background job queues alongside the gateway, the same always-on host solves both problems at once — workers hit the identical stateless-function wall as WebSockets do, for the identical reason.

A software engineer working in a server room, representing an always-on backend host

Option 2: Hand Realtime Delivery to a Managed Provider

If you'd rather not run and monitor another service, Ably, Pusher, or Supabase Realtime hold the persistent connection for you. Your NestJS app — still happily on Vercel — publishes events over a normal HTTP call, and the provider fans them out to connected clients over their own infrastructure.

TypeScript
1// notifications.service.ts — runs fine inside a Vercel function,
2// because publishing is a single request/response, not a held-open socket
3import { Injectable } from '@nestjs/common';
4import Ably from 'ably';
5
6@Injectable()
7export class NotificationsService {
8  private readonly ably = new Ably.Rest(process.env.ABLY_API_KEY);
9
10  async notifyTenant(tenantId: string, payload: Record<string, unknown>) {
11    const channel = this.ably.channels.get(`tenant:${tenantId}`);
12    await channel.publish('notification', payload);
13  }
14}

This is architecturally the same shape as our WebSockets vs SSE vs polling comparison for NestJS — you're picking the transport that matches your constraints, and "I'm deploying to a platform that can't hold a socket open" is a very real constraint, not a lesser one. NestJS's own WebSockets gateway documentation is written assuming a persistent host; it's correct, it's just answering a different deployment question than the one Vercel forces on you.

Developer writing NestJS code to wire a managed realtime provider

Vercel vs. Always-On vs. Managed Realtime

Stays on Vercel?Ops overheadBest for
Always-on host (Railway/Render/VM)No, separate serviceMedium — you own uptime, scaling, Redis adapter for multi-instanceTeams who already run other always-on infra (workers, cron)
Managed realtime (Ably/Pusher/Supabase)Yes, publishing onlyLow — provider owns the connection layerSmall teams who want realtime without another service to babysit
Vercel serverless functions directlyNothing. This is the option that doesn't exist, no matter how the docs read on first pass.

The Opinion Part

Here's the strong version, since hedging into "it depends" wastes your time: if you're already deploying a NestJS backend to Vercel, you picked Vercel for the frontend, not the backend — and that's fine, plenty of teams run Next.js on Vercel and NestJS somewhere else entirely. Trying to force a stateful gateway into a stateless function runtime because "it's already deployed there" costs you more debugging hours than just standing up a second, tiny service ever will. A $5/month Railway instance that just runs your gateway is cheaper than the afternoon you'll spend reading Vercel's function logs looking for a bug that was never in your code.

Wiring the Next.js Frontend to Wherever the Gateway Actually Lives

Whichever option you pick, the frontend change is small — point the client at the new URL (an always-on host) or the provider's SDK (Ably/Pusher), instead of assuming the gateway lives at the same origin as your Vercel deployment:

TypeScript
1// app/_hooks/useRealtimeNotifications.ts
2import { useEffect } from 'react';
3import Ably from 'ably';
4
5export function useRealtimeNotifications(tenantId: string, onMessage: (data: unknown) => void) {
6  useEffect(() => {
7    const client = new Ably.Realtime({ authUrl: '/api/ably-token' });
8    const channel = client.channels.get(`tenant:${tenantId}`);
9    channel.subscribe('notification', (msg) => onMessage(msg.data));
10
11    return () => client.close();
12  }, [tenantId, onMessage]);
13}

The authUrl endpoint is a small Vercel-friendly API route that mints a short-lived Ably token — that part genuinely is a single request/response, so it runs on Vercel without complaint. Only the long-lived connection moves off-platform.

Conclusion

Your NestJS WebSocket isn't working on Vercel because Vercel's serverless functions were never designed to keep anything open — and once you stop treating that as a bug to squash, you can spend the afternoon shipping the fix instead of the gateway config. Move the socket somewhere that stays running, or let a provider hold it for you. Either way, you'll close this ticket today instead of reopening it every time someone deploys.

Fix the transport, not the decorator. And if you're standing at the fork between "run my own always-on gateway" and "let someone else hold the socket," and genuinely can't tell which side of that line your team should be on — that's a five-minute conversation for us, not a solo 2am debugging session for you.

Frequently Asked Questions

No, not directly. Vercel's serverless functions are stateless and ephemeral — each invocation spins up, handles a request, and exits, so there's no long-lived process to keep a WebSocket handshake alive. This applies regardless of your NestJS gateway configuration, adapter, or transport. The fix is architectural: host the WebSocket gateway on a separate always-on service, or hand realtime delivery to a managed provider, while the rest of your NestJS app stays on Vercel.

Locally, your NestJS process stays running, so the socket connection persists as long as the process does. On Vercel, the function that accepted the handshake can be frozen or recycled the moment the request/response cycle looks complete, which severs the connection. It's not a timeout you can configure around — it's a fundamental mismatch between what a WebSocket needs (a long-lived process) and what a serverless function provides (a short-lived one).

For a self-managed option, Railway, Render, or a small always-on VM/container all work well because they keep a single Node process running continuously. For a managed option with less operational overhead, Ably, Pusher, or Supabase Realtime handle the persistent connection layer for you, and your NestJS app just publishes events to them over regular HTTP.

Yes — it's the same root cause. BullMQ workers, long-running cron-style jobs, and anything else that expects a persistent process will behave the same way: they can't run reliably inside a function that Vercel is free to freeze or terminate once a request finishes. The fix pattern is identical — move the long-running piece off Vercel's serverless functions and onto an always-on service.

No. Increasing maxDuration extends how long a function is allowed to run before Vercel kills it, but it doesn't change the underlying execution model — the function is still ephemeral and not designed to hold a persistent bidirectional connection. It also doesn't help under concurrency, since a new invocation doesn't share the socket state of a previous one. Timeout settings solve a different problem than the one causing this.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio