Article

NestJS GraphQL WebSockets

Since the first iteration of

ecoeats

, I grappled with getting real-time data to customers, riders, merchants, and our operations team. Polling was easy to add. It was also very easy to add everywhere.


This is a retrospective on moving GraphQL traffic onto WebSockets, followed by an updated approach to subscriptions and token expiry. The historical client below records the original trade-off; the newer examples are illustrative integration code, not the original production implementation or a tested application. Updated 10 September 2026.

A million requests a day

A million requests a day. Ouch.

At the time of the original article, we operated in St Andrews, Scotland. Typical daily orders numbered in the hundreds, with 10+ delivery riders and 15+ merchants online at a given time.

So where do all the requests come from? Customers, Riders, Merchants and Admins. Every part of the ecoeats platform that looks real-time uses polling in order to get up-to-date info. From our ops dashboards requesting live capacity information, to Merchants' tablets that auto-refresh the list of orders, to a Rider's phone requesting live route information - it's polling all the way down.

One complication was authentication. A socket can outlive the JWT used to open it. Depending on how connection context and guards are wired, later operations may fail against that expired credential, or an established subscription may retain access longer than intended.


Neither outcome is fixed by simply choosing WebSockets. Connection lifetime and authorization lifetime are different things.

Everything over WebSockets?

Our shortcut was to send most queries and mutations down an established WebSocket, keeping uploads and authentication-related operations on HTTP.


Important distinction:

polling over a WebSocket is still polling

. This can reduce repeated HTTP framing and related processing, but it doesn't inherently reduce resolver calls, database work, or application-level messages. Modern HTTP connections can be reused too, so don't assume every HTTP request means another TCP handshake.


Here's the historical Ops dashboard configuration. It's included as a record, not current setup instructions: its upload, persisted-query, and subscription packages require compatible versions.


const graphQLClient = new ApolloClient({
link: ApolloLink.from([
errorLink,
createPersistedQueryLink() as any,
split(
(operation) => extractFiles(operation).files.size > 0,
new UploadLink({
uri: API_URL,
credentials: "include",
headers,
}),
split(
(operation) => {
const isHTTPOnly = [
"login",
"me",
"resetPasswordRequest",
"logout",
].includes(operation.operationName);
if (
operation.operationName === "login" ||
operation.operationName === "logout"
)
setTimeout(() => getSubscriptionClient()?.close(), 500);
return isHTTPOnly;
},
new BatchHttpLink({
uri: API_URL,
credentials: "include",
headers,
batchMax: 3,
}),
wsLink
)
),
]),
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: "no-cache",
},
},
name: "admin",
version: process.env.REACT_APP_GIT_SHA || "dev",
});

What the historical result shows


The historical result showed fewer HTTP requests after moving traffic to WebSockets. It doesn't, on its own, establish a reduction in total server work or prove lower end-to-end latency.

The old

setTimeout(..., 500)

reconnect logic is also not a lifecycle contract. It can race with login and logout. Tie connection changes to successful authentication transitions instead.

A better default: HTTP for operations, WebSockets for events

For a new application, I'd start with HTTP queries and mutations, then add subscriptions where something genuinely changes in real time. Keep uploads on a supported HTTP path. Only move other operations to a socket after measuring a benefit.


NestJS's Apollo driver supports the

graphql-ws

library:


import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
// Add this to your application's module imports.
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
subscriptions: {
'graphql-ws': { path: '/graphql' },
},
});

That's transport setup only, not an authenticated server. Add your resolvers and the authorization boundary described below before using it with private data.


Both ends must speak the same protocol. The

graphql-ws

library implements

graphql-transport-ws

; the older

subscriptions-transport-ws

library is not interchangeable with it.


For a browser application using Apollo Client 4, the split looks like this:


import {
ApolloClient,
HttpLink,
InMemoryCache,
split,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
export function createRealtimeClient(
httpUrl: string,
wsUrl: string,
getAccessToken: () => Promise<string>,
) {
const wsClient = createClient({
url: wsUrl,
lazy: true,
retryAttempts: 5,
connectionParams: async () => ({
authorization: `Bearer ${await getAccessToken()}`,
}),
});
const httpLink = new HttpLink({
uri: httpUrl,
fetch: async (uri, options) => {
const headers = new Headers(options?.headers);
headers.set('authorization', `Bearer ${await getAccessToken()}`);
return fetch(uri, { ...options, headers });
},
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
new GraphQLWsLink(wsClient),
httpLink,
);
return {
client: new ApolloClient({ link, cache: new InMemoryCache() }),
wsClient,
};
}

Supply your application's endpoints and token provider. Use HTTPS/WSS in production. This browser-only example assumes normal JSON HTTP operations, not multipart uploads or server-side rendering.


getAccessToken

must return a valid token or fail. If it refreshes credentials, deduplicate concurrent refreshes in that authentication service. Don't start a separate refresh request for every operation.

Token expiry is a lifecycle, not a header

connectionParams

runs when connecting. Updating a token in memory does

not

update an already-open connection.


The simplest refresh strategy is explicit:


  • Refresh the token through the normal authentication flow before it expires.

  • After refresh succeeds, restart the socket so the next handshake reads the new token.

  • Re-establish active subscriptions.

  • Fetch a fresh snapshot, or resume from a server-supported cursor, to cover the disconnected interval.

  • If refresh fails, stop subscriptions and return to a signed-out state instead of retrying indefinitely.


  • For

    graphql-ws

    , use its documented

    graceful-restart recipe

    when preserving subscriptions across a token refresh. That wrapper closes with a retryable application code and lets the library resubscribe.

    dispose()

    is permanent shutdown, not a synonym for restart.


    On logout or account switching, unmount protected subscribers, dispose the old socket, stop the old Apollo client, and discard or clear its cache before mounting a fresh authenticated client. Don't automatically replay the old user's subscriptions with the new user's credentials.


    On the NestJS side, verify credentials on connection and authorize each operation against the actor and requested resource. The

    onConnect

    callback can attach verified identity to

    extra

    , and the GraphQL context can expose it to guards. Reject missing credentials and verify JWT signature, expiry, issuer, and audience as appropriate; decoding a JWT isn't verification.


    Long-running subscriptions need a revocation policy too. Close connections at credential expiry, revalidate delivery when permissions change, or use another explicit server-side policy. A cooperative browser's refresh timer isn't a security control.

    Reconnecting isn't catching up

    Imagine an order changes while a merchant's tablet loses connectivity. Reopening the subscription doesn't tell you what happened during the gap.


    For a simple dashboard, refetch current state after reconnecting. For event processing, use durable cursors, ordering, and deduplication. Establishing a subscription and fetching a snapshot can itself introduce a race; use revisions or a server-supported resume protocol where missing a transition is unacceptable.


    Treat emitted events as committed facts: publish after the database operation succeeds. A multi-instance service also needs shared event distribution; an in-process PubSub won't reach clients connected to another instance.

    What I'd test before calling it done

  • Token expires while the screen is idle and while an operation is active.

  • A refresh fails, permissions are revoked, or the user switches accounts.

  • The browser sleeps, the network drops, or the server restarts.

  • A reconnect occurs between a snapshot and a live event.

  • Duplicate events arrive or a mutation's response is lost.

  • Many clients reconnect together and need bounded retries with jitter.


  • Don't blindly replay mutations after a disconnect. “No response” doesn't mean “not committed”; retries need operation-specific idempotency.


    Measure application operations, bytes transferred, resolver/database work, update latency, reconnects, and stale-screen duration. A prettier HTTP chart is nice. A merchant seeing the correct order is the actual goal.

    References




    Made with Intenso