NestJS GraphQL Subscriptions Not Working Behind Cloudflare

NestJS GraphQL subscriptions not working behind Cloudflare almost always looks the same: the subscription connects, sits for a beat, and closes — reliably, only in production, only once Cloudflare sits in front of the app. Locally it's rock solid. The resolver code is fine. Cloudflare's proxy has its own opinions about long-lived connections, and by default those opinions don't automatically include the one your subscription needs.
Short answer: Cloudflare's proxy buffers and can apply idle timeouts to WebSocket traffic unless it's explicitly configured to treat your subscription endpoint correctly. Confirm WebSocket support is actually enabled for the zone, check for Page Rules or Workers touching the subscription route, and if the connection still drops on idle periods, add a lightweight keep-alive ping so the socket never looks idle long enough to get closed.

Why Cloudflare Breaks NestJS GraphQL Subscriptions by Default
A GraphQL subscription over WebSocket is fundamentally a connection that's supposed to stay open and quiet between events — no traffic for seconds or minutes at a time is normal, not a sign of failure. Cloudflare's proxy sits directly in the path of that connection, and depending on your zone's configuration, it can treat a long quiet period as a connection worth closing, or buffer traffic in a way that delays or drops the upgrade handshake entirely. None of this is a bug in Cloudflare — it's a proxy doing proxy things to traffic it wasn't specifically told to leave alone.
(If your first move was adding retry logic to the subscription client so it silently reconnects every time this happens — that's a workaround, not a fix, and it's the kind of workaround that turns into "why does our real-time feature flicker" six months later. Fix the connection, not the reconnect loop.)
Confirming WebSocket Support Is Actually Enabled
Before touching any code, confirm the zone-level setting. Cloudflare's dashboard has WebSocket support as an explicit toggle under network settings on some plan tiers — it's usually on by default, but "usually" is doing a lot of work in a sentence about a bug you're currently debugging. Confirm it directly rather than assuming.

Confirming Cloudflare Is Actually the Cause
Before changing any settings, rule out the obvious alternative: temporarily route traffic directly to your origin (bypassing Cloudflare's proxy, either via a DNS-only record or a direct IP for testing) and see if the subscription holds. If it stays connected without Cloudflare in the path and drops the moment the proxy is back in front of it, that's confirmation — not a guess. This five-minute check saves you from tuning Page Rules for an hour only to discover the actual bug was in your own gateway's CORS config the whole time.
Checking Page Rules and Workers for Interference
GraphQL subscriptions typically run over a specific path — often the same /graphql endpoint as your regular queries and mutations, distinguished by the upgrade header rather than a separate route. A Page Rule or Cloudflare Worker written before subscriptions existed in your app can apply caching, a redirect, or a transformation to that path without anyone realizing it now intersects with a WebSocket upgrade request. Cloudflare's own Page Rules documentation is worth a direct read here — audit anything touching /graphql specifically, not just anything that looks WebSocket-related by name.
Adding a Keep-Alive to Survive Idle Timeouts
If WebSocket support is confirmed enabled and no Page Rule is interfering, the remaining common cause is an idle timeout closing quiet connections. The fix is a small, deliberate ping that keeps the socket looking active:
1// subscriptions.gateway.ts
2import { WebSocketGateway, OnGatewayConnection } from '@nestjs/websockets';
3import { WebSocket } from 'ws';
4
5@WebSocketGateway()
6export class SubscriptionsGateway implements OnGatewayConnection {
7 handleConnection(client: WebSocket) {
8 const keepAlive = setInterval(() => {
9 if (client.readyState === client.OPEN) {
10 client.ping();
11 }
12 }, 25_000); // comfortably under most proxy idle-timeout windows
13
14 client.on('close', () => clearInterval(keepAlive));
15 }
16}For graphql-ws specifically, the library supports connection-level ping/pong out of the box — configuring it correctly is usually simpler than hand-rolling a keep-alive, and it's the more maintainable long-term fix once you've confirmed the proxy settings are otherwise correct. If you're choosing between GraphQL and a plain REST API for this project at all, our GraphQL vs REST decision guide covers where subscriptions specifically tip that decision one way or the other.

The Opinion Part
Here's the honest version, and it applies to more than just this bug: most "real-time" features don't actually need a subscription. A notification badge, a status indicator, a dashboard metric that updates every few seconds — these tolerate polling or Server-Sent Events just fine, and both sidestep this entire category of proxy-and-WebSocket configuration problem entirely. Reach for a genuine subscription when the delay would actually be noticeable to a user — a live chat, a collaborative editor, anything where a two-second lag reads as broken rather than acceptable. If you're three Cloudflare settings deep debugging a subscription that's driving a badge counter, that's a signal worth listening to, not just a bug worth fixing.
Conclusion
NestJS GraphQL subscriptions failing behind Cloudflare is a proxy configuration problem wearing a resolver-shaped disguise. Confirm WebSocket support is on, audit Page Rules and Workers touching your subscription path, and add a proper keep-alive so idle periods stop looking like dead connections. Once those three line up, the subscription behaves exactly like it does locally — quietly, reliably, and without anyone needing to know Cloudflare was ever in the way.
If this is the second WebSocket-adjacent bug you've chased across two different platforms this month, our breakdown of why WebSockets specifically break on Vercel is worth a read too — different platform, same underlying pattern of "persistent connections need infrastructure that was built to expect them."
Frequently Asked Questions
A GraphQL subscription over WebSocket is a long-lived connection, and Cloudflare's proxy applies its own timeout and buffering behavior to traffic passing through it by default. If WebSocket support isn't explicitly confirmed enabled for the zone, or if an idle timeout closes the connection before your subscription sends its next event, the client sees the socket close and reconnects — often immediately, in a loop.
Yes — Cloudflare has supported WebSocket proxying for years, and it's enabled by default on most plans. The problem is almost never 'Cloudflare can't do this,' it's a specific setting, timeout, or Page Rule interacting badly with a long-lived connection. Confirm WebSocket support is active for your zone and check for any Page Rules that might be caching or altering behavior on the subscription endpoint specifically.
Confirm WebSockets are enabled under Network settings for the zone (Cloudflare's dashboard has this as an explicit toggle on some plan tiers). Beyond that, check for Page Rules or Workers that might apply caching, redirects, or transformations to the subscription route — GraphQL subscriptions usually run over a specific path like /graphql, and a rule that wasn't written with that path in mind can interfere without an obvious error.
For a surprising number of 'real-time' features, yes. Most subscription use cases — a notification badge, a status update, a dashboard metric — tolerate a few seconds of delay just fine, and polling or Server-Sent Events sidestep the entire class of proxy/WebSocket configuration problem. Reserve true subscriptions for genuinely latency-sensitive features, like a live chat or collaborative editor, where the delay would actually be noticeable.
The underlying mechanism is identical — any long-lived WebSocket connection through Cloudflare's proxy is subject to the same buffering and timeout behavior, whether it's carrying GraphQL subscription events or raw Socket.io traffic. The fix (confirm WebSocket support, check timeouts and Page Rules) applies the same way regardless of which library is riding on top of the socket.
