gql_sse_link
⚠️ Disclaimer: This is beta software, mostly generated by AI. We are still in the process of reviewing and testing it — use at your own risk. It is published anyway because it brings the number of options for GraphQL with SSE subscriptions in the gql_link ecosystem from 0 to 1. Contributions welcome!
GQL link to execute GraphQL subscriptions over Server-Sent Events using the graphql-sse "distinct connections" protocol.
SseLink is a terminating link that specialises in subscriptions. Queries
and mutations should be routed to a different terminating link (typically
HttpLink) via Link.split.
Usage
import "package:gql_exec/gql_exec.dart";
import "package:gql_http_link/gql_http_link.dart";
import "package:gql_link/gql_link.dart";
import "package:gql_sse_link/gql_sse_link.dart";
void main() {
final link = Link.split(
(request) =>
request.operation.getOperationType() == OperationType.subscription,
SseLink("https://example.com/graphql/stream"),
HttpLink("https://example.com/graphql"),
);
}
HTTP/2 and HTTP/3
The SSE endpoint is called via package:http, which defaults to dart:io's
HttpClient on io platforms — HTTP/1.1 only. To get HTTP/2 or HTTP/3
(connection multiplexing, QUIC), inject a different http.Client:
| Platform | Client | Protocols |
|---|---|---|
| Android | cronet_http |
HTTP/1, HTTP/2, HTTP/3 |
| iOS/macOS | cupertino_http |
HTTP/1, HTTP/2, HTTP/3 (iOS 15+) |
| Web | browser (fetch) | HTTP/1, HTTP/2, HTTP/3 |
SseLink(
"https://example.com/graphql/stream",
httpClient: CronetClient.defaultCronetEngine(),
);
Reconnection
Subscriptions are long-lived, so the underlying connection can drop
mid-stream — the OS stalls the app/tab, the server closes the stream, or the
network blips. When that happens the transport either errors (on web:
ClientException: Error in input stream) or the stream simply ends without an
event: complete. SseLink treats both as transient: it transparently
re-issues the POST in the background with exponential backoff, so consumers
see one continuous response stream that does not error on a transient drop.
No changes are needed downstream.
Terminal conditions are not retried:
- An explicit
event: completecloses the stream for good. - Deterministic failures propagate as errors instead of spinning forever:
HTTP
4xx(e.g.400/401/403), malformednextpayloads (SseLinkParserException), and request-format/context errors.
Transient failures are retried: network errors, dropped/aborted streams, and
HTTP 5xx.
Cancelling the subscription (e.g. navigating away) aborts the in-flight HTTP response cleanly and silently — no aborted-stream error escapes to the zone.
Configuration
The policy is configurable via optional constructor parameters, modelled on
gql_websocket_link. The
public API is backward compatible; the defaults suit live subscriptions.
| Parameter | Type | Default | Description |
|---|---|---|---|
retryAttempts |
int |
SseLink.unlimitedRetries (-1) |
Max consecutive reconnects before the stream errors out. -1 reconnects indefinitely. The counter resets after a connection stays healthy for retryHealthyThreshold. |
retryWait |
Future<void> Function(int retries) |
SseLink.randomizedExponentialBackoff |
Backoff schedule; awaited before each reconnect. retries starts at 0 for the first reconnect after a healthy connection. |
shouldRetry |
bool Function(Object error) |
SseLink.shouldRetryDefault |
Classifies a transport failure as transient (true → reconnect) or deterministic (false → propagate). |
retryHealthyThreshold |
Duration |
Duration(seconds: 30) |
Once a connection has stayed open this long, the next drop restarts the backoff from its first step. |
connectionParams |
FutureOr<Map<String, String>?> Function()? |
null |
Resolves extra HTTP headers on every (re)connection. Its headers override defaultHeaders and the request's context headers. |
SseLink(
"https://example.com/graphql/stream",
// Stop after 10 consecutive failed reconnects instead of retrying forever.
retryAttempts: 10,
// Custom backoff: fixed 5s between attempts.
retryWait: (retries) => Future<void>.delayed(const Duration(seconds: 5)),
);
The default randomizedExponentialBackoff starts at a 1s base delay, doubles
per attempt up to a 30s cap, and adds a random 300ms–3s jitter.
Refreshing auth on reconnect
Because a reconnect re-issues the original POST, headers baked into the
request (e.g. by an AuthLink) are not refreshed — a bearer token that
expired mid-subscription would be replayed stale. Use connectionParams to
resolve headers fresh on every attempt instead:
SseLink(
"https://example.com/graphql/stream",
connectionParams: () async => {
"Authorization": "Bearer ${await tokenStore.currentAccessToken()}",
},
);
connectionParams is the SSE analog of gql_websocket_link's parameter of the
same name; since SSE authenticates over HTTP headers rather than a
connection_init payload, it returns headers. A failure while resolving them
(e.g. the token endpoint is unreachable) is treated as a transient drop and
retried, unless your shouldRetry classifies the thrown error as terminal.
Non-goal —
Last-Event-IDresumption. The distinct-connections mode re-executes the subscription from scratch on every connection, and these subscriptions stream full current state on each event, so a plain re-POSTis sufficient. The link does not sendLast-Event-ID.
Protocol notes
This link implements the distinct connections mode of
graphql-sse: each subscription is a single POST
with Accept: text/event-stream whose response is a stream of SSE
events:
event: next—data:is a GraphQLExecutionResult. Parsed via the standardResponseParserand emitted on the link's response stream.event: complete— closes the response stream.
The single connection mode (reservation tokens + multiplexed operations) is not implemented. With HTTP/2 or HTTP/3, distinct connections multiplex over a single transport connection anyway, so there is no meaningful overhead.
Libraries
- gql_sse_link
- GQL Server-Sent Events link for GraphQL subscriptions (graphql-sse "distinct connections" mode).