GraphQL Subscriptions Without WebSockets: The SSE Escape Hatch

Every GraphQL tutorial on subscriptions starts the same way: "First, upgrade your HTTP connection to a WebSocket." Then comes the lecture on graphql-ws, the Sec-WebSocket-Protocol header, ping/pong frames, and the eight ways your connection can die without explanation. You nod through it, ship your subscription, and a month later a customer calls because their corporate firewall blocks wss:// and nothing works. Welcome to 2026, where GraphQL over Server-Sent Events has quietly become the pragmatic alternative that just works.
What SSE Is, And Why It Matters
Server-Sent Events is a browser-native protocol for server-to-client push. It's been part of HTML5 since the late 2000s and is roughly contemporaneous with WebSockets in browser support (Chrome and Safari shipped EventSource in 2010, Firefox in 2011). And critically:
SSE is just HTTP. The client opens a normal GET request, asks for
text/event-stream, and the server writes events to the response body whenever it wants.
That's it. No upgrade handshake. No new protocol. No secondary port. Every single piece of HTTP infrastructure (load balancers, proxies, CDNs, corporate firewalls, VPNs, service meshes) treats SSE like any other long-lived HTTP response. Which, structurally, it is.
GET /graphql/stream HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: next
data: {"data":{"messageAdded":{"id":"1","text":"hello"}}}
event: next
data: {"data":{"messageAdded":{"id":"2","text":"world"}}}
event: complete
data:
Events are separated by blank lines. The connection stays open. The server writes when it has something to say. The client reads. Done.
Compare this to WebSockets:
- Client sends
Upgrade: websocket+ magicSec-WebSocket-Keyheader - Server responds 101 Switching Protocols with a computed handshake key
- Connection is now full-duplex binary framing
- You need ping/pong frames to keep the connection alive
- Half the middleboxes on the internet don't understand it
SSE avoids this entire circus.
The WebSocket Problem Nobody Admits To
WebSockets work beautifully in Chrome on your office wifi. They break in places you won't discover until it's too late.
- Corporate proxies that inspect traffic and don't speak Upgrade headers
- Legacy load balancers that drop long-lived WebSocket connections at 60 seconds
- Service meshes that don't forward WebSocket frames correctly (looking at you, older Istio)
- Some CDNs don't proxy WebSockets well or charge more for them
- Mobile network transitions (wifi to LTE) that silently kill the socket
- HTTP/1.1 with certain intermediaries that buffer the 101 response
Every team that ships WebSocket-based subscriptions eventually gets a support ticket that reads: "subscriptions work fine for me but our enterprise customer in [BigCorp] can't connect." The fix is typically "deploy SSE as a fallback." The better fix is "use SSE from the start."
Meet graphql-sse
graphql-sse is the reference implementation of the GraphQL over SSE protocol. Same author as graphql-ws. The protocol is short, explicit, and designed to be a drop-in alternative.
Two modes:
Distinct Connections Mode
Each subscription opens its own SSE stream. Simplest model. Works over both HTTP/1.1 and HTTP/2, though it's really meant for HTTP/2 - on HTTP/1.1 each stream consumes one of the browser's ~6 connections per origin (see below).
GET /graphql/stream?query=subscription+MessageAdded+%7BmessageAdded%7Bid+text%7D%7D HTTP/1.1
Accept: text/event-stream
Events flow until the subscription completes, the client closes, or an error occurs.
Single Connection Mode
One long-lived SSE connection multiplexes all subscriptions. Better for clients with many subscriptions. Protocol is slightly more involved but still entirely HTTP.
# 1. Reserve a stream token
PUT /graphql/stream HTTP/1.1
# Server responds 201 Created with a token in the body
# 2. Open the SSE stream using that token
GET /graphql/stream?token=ABC HTTP/1.1
Accept: text/event-stream
# 3. Execute operations against the reserved stream
POST /graphql/stream?token=ABC HTTP/1.1
Content-Type: application/json
{ "query": "subscription MessageAdded { ... }" }
# 4. Terminate operations (or DELETE to close the stream)
Both modes are documented in the graphql-sse protocol. Server support varies: GraphQL Yoga (via its @graphql-yoga/plugin-graphql-sse plugin) and Hot Chocolate (built in since v13) expose GraphQL-over-SSE natively on the server. Apollo Server does not ship a first-party SSE transport - its official subscription transport is WebSocket via graphql-ws - so SSE there requires a custom plugin or third-party integration. Browser-side, you typically use the graphql-sse library's own createClient and either consume it directly or wrap it in a custom ApolloLink / urql exchange. No first-party Apollo Client subpath covers graphql-sse; community packages exist for the link pattern.
Setting It Up on Spring Boot
Spring's native SSE support via WebFlux makes this fairly painless. @SubscriptionMapping is transport-agnostic, so you do not need a separate resolver for SSE. Since Spring for GraphQL 1.3 (2024) the framework ships a built-in GraphQlSseHandler that serves your existing @SubscriptionMapping methods over text/event-stream. In Spring Boot 3.3+ it is auto-configured alongside the /graphql endpoint, tunable via spring.graphql.http.sse.timeout and spring.graphql.http.sse.keep-alive. The same subscription resolver is then available over BOTH WebSocket and SSE, with no extra code.
If you need custom framing or single-connection mode, you can drop down to a controller of your own. Here's the minimal sketch - roughly what the built-in handler does internally, offered as an optional lower-level alternative instead of a requirement:
@RestController
@RequestMapping("/graphql/stream")
public class GraphQlSseController {
private final ExecutionGraphQlService graphQlService;
public GraphQlSseController(ExecutionGraphQlService graphQlService) {
this.graphQlService = graphQlService;
}
@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<Map<String, Object>>> stream(
@RequestParam String query,
@RequestParam(required = false) String operationName,
@RequestParam(required = false) Map<String, Object> variables) {
ExecutionGraphQlRequest request = new DefaultExecutionGraphQlRequest(
query, operationName, variables, null, UUID.randomUUID().toString(), null
);
return Mono.fromFuture(graphQlService.execute(request).toFuture())
.flatMapMany(response -> {
Object data = response.getData();
if (data instanceof Publisher<?> publisher) {
return Flux.from(publisher)
.map(event -> ServerSentEvent.<Map<String, Object>>builder()
.event("next")
.data(Map.of("data", event))
.build())
.concatWith(Mono.just(
ServerSentEvent.<Map<String, Object>>builder()
.event("complete")
.data(Map.of())
.build()
));
}
return Flux.just(ServerSentEvent.<Map<String, Object>>builder()
.event("next")
.data(response.toMap())
.build());
});
}
}
This is sketchy production code; you'd want authentication, POST support for large queries (GET has URL length limits), error handling, and heartbeat events to keep the connection alive through idle proxies. But it's the right shape.
Long-lived HTTP connections get killed by intermediaries after 30 to 120 seconds of idleness. Send a comment line (:heartbeat) every 15 to 30 seconds. SSE clients ignore comment lines but the TCP keepalive keeps proxies happy.
Flux.interval(Duration.ofSeconds(15))
.map(i -> ServerSentEvent.<Map<String, Object>>builder()
.comment("heartbeat")
.build());
Merge this into your main event flux. Your connections will stop dying at minute three.
Client-Side: Apollo Using SSE
There's no first-party Apollo Client subpath for graphql-sse, so you wrap the official graphql-sse client in a custom ApolloLink. The pattern is short and replaces GraphQLWsLink cleanly:
import { ApolloClient, InMemoryCache, split, HttpLink, ApolloLink, Observable } from '@apollo/client';
import { createClient } from 'graphql-sse';
import { getMainDefinition, print } from '@apollo/client/utilities';
const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' });
const sseClient = createClient({
url: 'https://api.example.com/graphql/stream',
});
// Minimal Apollo link that delegates to graphql-sse for subscriptions.
const sseLink = new ApolloLink((operation) => new Observable((sink) => {
return sseClient.subscribe(
{ ...operation, query: print(operation.query) },
{ next: sink.next.bind(sink), error: sink.error.bind(sink), complete: sink.complete.bind(sink) },
);
}));
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
sseLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
The split link routes subscriptions through SSE and everything else through regular HTTP. Your React components that use useSubscription don't change.
The Transport Decision Tree
You need real-time. Which transport do you pick?
| Your Constraint | WebSocket | SSE | Polling |
|---|---|---|---|
| Works through corporate firewalls | Sometimes | Yes | Yes |
| Bidirectional (client can push too) | Yes | No | No |
| HTTP/2 multiplexing compatible | Awkward | Yes | Yes |
| CDN-friendly | Usually no | Yes | Yes |
| Browser native | Yes | Yes | Yes |
| Low-level control | High | Medium | Low |
| Chatty client interactions | Good | Bad | Bad |
- Your subscription data flows server to client only (the typical case)
- Your clients include enterprise networks where WebSockets get blocked
- You want subscriptions to play nicely with existing HTTP infrastructure
- You want one consistent transport for everything (HTTP for queries, HTTP for mutations, HTTP for subscriptions)
- You need the client to actively push data through the same connection (rare in GraphQL)
- Your backend is already WebSocket-native (gaming, collaborative editors)
- You're inside a controlled network where firewalls aren't a concern
- You need millisecond latency both ways (WebSocket has lower per-message overhead)
- You don't actually need real-time, you need "eventually consistent"
- Your scale is small and the simplicity of polling is worth the latency
- You want zero long-lived connections in your infrastructure
For most GraphQL subscription use cases in 2026, SSE is the right answer. Notifications, chat, live-updating dashboards, prices on a trading screen: all server-to-client pushes with no need for the client to talk back over the same channel.
The Part That's Slightly Worse
SSE is not objectively better than WebSockets. There are real tradeoffs:
-
No built-in message framing. SSE gives you lines of text. WebSockets give you discrete frames. You build your own boundaries (easy with the graphql-sse protocol).
-
No binary support. SSE is text-only. If you want to stream binary data, you're base64 encoding it or using a separate channel.
-
One-way only. The client cannot push through the SSE stream. If you need a cancel message or a pong-response, you do it with a separate HTTP request.
-
Connection limit in HTTP/1.1. Browsers limit concurrent connections to the same origin (6 typical). If you have 7 SSE streams open, the 7th blocks. HTTP/2 multiplexes, so this is mostly a legacy concern.
-
Debugging is mildly annoying. Chrome DevTools doesn't show SSE streams as nicely as WebSocket frames. You get raw text in the Network tab.
None of these are deal-breakers for typical GraphQL subscription workloads. They matter if you're building something that really wants bidirectional streaming.
The Hidden Bonus: @defer and @stream Come Along
Remember @defer and @stream? They also produce multi-response streams. And guess what, they work over SSE too. One transport for:
- Queries (single response)
- Mutations (single response)
- Subscriptions (many responses over time)
- Incremental delivery (many responses quickly)
Instead of your server having to juggle HTTP for some things and WebSockets for others, you have one HTTP-based mechanism that handles all four. Simpler topology, fewer failure modes, fewer special cases in your infrastructure.
What This Looks Like in Production
A team I know migrated their chat app from WebSocket subscriptions to SSE. They wrote up three numbers:
- Zero customer firewall complaints in the 90 days after the switch (they had two per week before)
- 40% reduction in subscription connection errors overall (SSE retries are more predictable)
- One added dependency on the client, one added endpoint on the server
The migration took about a week. They kept the WebSocket endpoint live for six months as a fallback. They removed it last quarter. Nobody noticed.
The Bigger Picture
The GraphQL community spent years assuming WebSockets were the subscription answer. graphql-ws got all the early ecosystem love. Documentation defaulted to it. Talks at conferences centered on it. And it worked, for a time, for most teams.
What changed is that HTTP itself got better. HTTP/2 multiplexing. HTTP/3 over QUIC. Serverless and edge platforms that all speak HTTP but treat WebSockets as a second-class citizen. The infrastructure moved under our feet while we were busy debating client libraries.
SSE was always waiting. It's been a web standard for over a decade. It runs on any HTTP server. It degrades gracefully. It was available the whole time, we just weren't listening.
If you're greenfield on GraphQL subscriptions today, start with SSE. If you're on WebSocket subscriptions and your infrastructure is holding up, don't rush a migration; there's nothing wrong with WebSockets when they work. But the next time you have to touch that code or debug a subscription mystery in production, give graphql-sse an honest evaluation. The industry has quietly moved on.
This post was streamed over HTTP/3 via your browser, which likely buffered it in a service worker, served it from a CDN, and completely ignored the transport layer. Such is the beauty of boring standards.