GraphQL Subscriptions vs. Webhooks: Choosing the Right Event-Driven Pattern
GraphQL Subscriptions vs. Webhooks: Choosing the Right Event-Driven Pattern When you're designing an event-driven API, one of the biggest architectural decisions is how you...

GraphQL Subscriptions vs. Webhooks: Choosing the Right Event-Driven Pattern
When you're designing an event-driven API, one of the biggest architectural decisions is how you propagate state changes across your system. Two patterns dominate real-time data distribution: GraphQL Subscriptions and Webhooks.
Both patterns replace the resource-wasteful practice of HTTP short-polling, but they solve fundamentally different problems at opposite ends of the architectural spectrum. GraphQL Subscriptions maintain persistent WebSocket (or SSE) connections designed primarily for client-to-server, UI-facing interactions. Webhooks rely on stateless, asynchronous HTTP POST callbacks optimized for decoupled backend-to-backend communication.
This guide breaks down the mechanics, resource efficiency, serverless compatibility, security models, and code implementations of each — including how they intersect in a typical Apollo Server setup, and how the tooling around both has shifted heading into 2026.
Executive Summary & Comparison Table
| Feature / Dimension | GraphQL Subscriptions | Webhooks |
|---|---|---|
| Primary Use Case | Real-time UI updates (Client-to-Server) | Asynchronous notifications (Server-to-Server) |
| Transport Protocol | WebSockets (ws:///wss://) or Server-Sent Events | Stateless HTTP / HTTPS POST |
| Connection Type | Stateful, persistent, long-lived | Stateless, request-response, ephemeral |
| Directionality | Bidirectional handshake / server-push after that | Unidirectional (server pushing to server) |
| Payload Customization | Dynamic — defined by the client's GraphQL selection set | Static — defined by the publisher's schema |
| Server Resource Overhead | Higher memory/file-descriptor footprint per client | Low; scales with request volume, not connection count |
| Serverless Compatibility | Historically awkward; improving via managed pub/sub (see below) | Native — maps cleanly to Lambda/Edge functions |
| Reliability & Retries | Connection-bound; client must resubscribe on drop | Publisher-side retry queues and dead-letter queues |
| Security Mechanism | Auth during the connection handshake (tokens/headers) | HMAC signatures, IP allow-listing, mutual TLS |
What Are GraphQL Subscriptions?
GraphQL Subscriptions are a GraphQL operation type that lets a server push real-time updates to subscribed clients whenever a specific event occurs.
Unlike Queries (read) and Mutations (write), which run over a standard request-response cycle, Subscriptions are long-lived operations that stay open for the lifetime of the client's interest in that data.
+------------------+ 1. WebSocket Connection Handshake +-------------------+
| | -----------------------------------------------> | |
| | 2. SUBSCRIBE: subscription { ... } | |
| Browser / Client | -----------------------------------------------> | Apollo Server |
| (Apollo/Urql) | | (Pub/Sub Engine)|
| | <----------------------------------------------- | |
+------------------+ 3. Real-Time Data Push (Event Payload) +-------------------+
Technical Mechanics
- Protocol handshake: The client initiates an HTTP upgrade to establish a WebSocket connection (using the
graphql-transport-wsprotocol, implemented by thegraphql-wslibrary), or opens a Server-Sent Events stream viagraphql-sse. - Subscription registration: The client sends a subscription query defining exactly the shape of data it wants:
Code example
subscription OnOrderUpdated($orderId: ID!) { orderUpdated(id: $orderId) { id status estimatedDelivery } } - Event publishing: When an event occurs (say, an order status changes via a mutation), the server's pub/sub layer triggers the subscription resolver and pushes the transformed result to every matching active connection.
Major Strengths
- Zero over-fetching: The client specifies exactly the fields it needs, so a bandwidth-constrained mobile client only pulls down
status, not the whole object graph. - Unified schema: Subscriptions share the same schema, types, and auth context as your existing queries and mutations.
- Cache integration: Apollo Client and Urql automatically merge incoming subscription payloads into their normalized caches, triggering UI re-renders without extra plumbing.
Major Drawbacks
- Higher memory footprint: Every open socket consumes kernel buffer memory and a file descriptor, so servers holding tens of thousands of concurrent connections need real capacity planning (OS
ulimittuning, load balancer socket limits, etc.) — the exact bytes-per-socket vary by OS and TCP buffer settings, but the direction is always "more connections, more baseline memory," unlike stateless HTTP. - Distributed state complexity: Scaling subscriptions across multiple server instances requires an external message broker (Redis Pub/Sub, NATS, Kafka) so an event published on one node reaches clients connected to another.
Note on tooling:
subscriptions-transport-ws, the original Apollo-created WebSocket transport, has been unmaintained since 2018 and its repository is now archived. If you see it in a tutorial, treat that as a signal the content is outdated — the actively maintained, Apollo-recommended replacement isgraphql-ws, which implements the newergraphql-transport-wsprotocol. The two protocols are not wire-compatible, so migrating means upgrading both client and server.
What Are Webhooks?
Webhooks (sometimes called "reverse APIs" or HTTP callbacks) are subscriber-defined HTTP endpoints that react to events in someone else's system. When an event happens in the publishing system, it serializes the event and POSTs it directly to a pre-registered URL owned by the subscriber.
+-------------------+ +-------------------+
| | 1. Event Occurs (e.g., Payment) | |
| Publishing System | ---------------------------------------------> | Receiving Server |
| (e.g., Stripe) | HTTP POST payload + HMAC Signature | (Webhook Handler)|
| | | |
| | <---------------------------------------------- | |
+-------------------+ 2. HTTP 200 OK Response +-------------------+
Technical Mechanics
- Registration: System B registers an HTTPS endpoint (
https://api.system-b.com/webhooks/orders) with System A. - Event dispatch: When an event occurs in System A, it serializes the event data to JSON and sends a
POSTrequest to System B's URL. - Acknowledgment and retries: System B processes the payload and returns a status code (
200 OKor202 Accepted). If it returns a5xxor times out, System A queues the event for retry with exponential backoff.
Major Strengths
- Stateless and scalable: No open connections to maintain — receivers handle each delivery as ordinary HTTP traffic and can scale to zero when idle.
- Reliability infrastructure is standard practice: Mature webhook publishers pair delivery with a queue (SQS, Kafka) plus retries and dead-letter queues, so temporary receiver downtime doesn't mean lost events.
- Language- and protocol-agnostic: Any server that can parse an HTTP POST body can receive a webhook.
Major Drawbacks
- Fixed payload shapes: Subscribers get whatever JSON the publisher decided to send, which often means a follow-up API call to fetch missing context.
- Public exposure: Receiving endpoints must be reachable from the public internet, which makes signature verification (not just "security through obscurity") mandatory.
- No standardization, historically: Every provider invented its own header names, signing scheme, and retry cadence — this is exactly the gap the Standard Webhooks initiative (covered below) is trying to close.
The Architectural Conflict: Statefulness vs. Scalability
Choosing between GraphQL Subscriptions and Webhooks comes down to balancing connection statefulness against infrastructure scalability.
+-------------------------------------+
| Is the consumer a frontend app |
| or web browser needing instant UI? |
+-------------------------------------+
/ \
/ \
YES NO
/ \
v v
+--------------------------+ +--------------------------+
| Use GraphQL Subscriptions| | Is it a server-to-server |
| (over WebSockets / SSE) | | integration across system |
+--------------------------+ | boundaries? |
+--------------------------+
|
| YES
v
+--------------------------+
| Use Webhooks |
+--------------------------+
Connection Exhaustion: The WebSocket Bottleneck
A single Node.js process running Apollo Server can handle a very large volume of stateless HTTP requests per second, because each connection closes as soon as the response is sent.
Over WebSockets, it's different: every subscribed client holds a persistent TCP socket open indefinitely. That has real costs:
- Memory: Each open socket reserves kernel send/receive buffers, so total memory scales roughly linearly with connection count — the specific per-socket number depends heavily on your OS and network stack tuning, but it's a cost stateless HTTP simply doesn't have.
- File descriptors: Unix treats sockets as file descriptors, so high concurrency runs into
ulimit -nand load-balancer connection caps unless you tune for it deliberately. - Heartbeats: To detect dead connections (a phone switching from Wi-Fi to cellular, for instance), servers send periodic pings, which adds constant low-level CPU and bandwidth overhead.
The Serverless & Edge Disconnect
Modern infrastructure leans heavily on serverless runtimes — AWS Lambda, Cloudflare Workers, Vercel Functions — that spin up on demand and terminate when the response finishes.
- Webhooks thrive here: an incoming webhook triggers a stateless function execution, processes the payload in tens of milliseconds, returns
200 OK, and terminates. Cost tracks execution time directly. - GraphQL Subscriptions historically struggled here: a standard serverless function can't hold a WebSocket open for hours, so teams offloaded connection management to an external stateful layer — managed WebSocket gateways backed by a database, or third-party real-time platforms like Ably, Pusher, or AWS AppSync.
That gap has narrowed. As of March 2025, AWS AppSync Events provides a managed, serverless WebSocket pub/sub API specifically so teams don't have to hand-roll connection management on top of Lambda and DynamoDB — you publish events over HTTP and AppSync handles fan-out to connected WebSocket clients. It's a purpose-built pub/sub product that sits alongside (not strictly inside) AppSync's original GraphQL subscription model, and it's one of a few signs that "subscriptions on serverless" is becoming a solved problem rather than a workaround.
Code Implementations
1. GraphQL Subscription Implementation (graphql-ws + Apollo Server)
This sets up a subscription server using Node.js, graphql-ws, and Apollo Server, letting clients subscribe to real-time commentAdded events. Note this already uses graphql-ws, not the deprecated subscriptions-transport-ws — that's the correct, currently supported approach.
import { createServer } from 'http';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub } from 'graphql-subscriptions';
import bodyParser from 'body-parser';
const pubsub = new PubSub();
const COMMENT_ADDED = 'COMMENT_ADDED';
// 1. Schema Definition
const typeDefs = `#graphql
type Comment {
id: ID!
content: String!
author: String!
}
type Query {
comments: [Comment!]!
}
type Mutation {
addComment(content: String!, author: String!): Comment!
}
type Subscription {
commentAdded: Comment!
}
`;
// 2. Resolvers
const resolvers = {
Query: {
comments: () => [],
},
Mutation: {
addComment: (_, { content, author }) => {
const newComment = { id: Date.now().toString(), content, author };
// Publish event to subscribers
pubsub.publish(COMMENT_ADDED, { commentAdded: newComment });
return newComment;
},
},
Subscription: {
commentAdded: {
subscribe: () => pubsub.asyncIterator([COMMENT_ADDED]),
},
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
// 3. HTTP and WebSocket Server Setup
const app = express();
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// Bind WebSocket server with GraphQL schema
const serverCleanup = useServer({ schema }, wsServer);
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', bodyParser.json(), expressMiddleware(server));
httpServer.listen(4000, () => {
console.log('🚀 Query/Mutation server ready at http://localhost:4000/graphql');
console.log('🚀 Subscription server ready at ws://localhost:4000/graphql');
});
2. Secure Webhook Publisher & Receiver with HMAC Verification
Below is a webhook receiver verifying an HMAC-SHA256 signature with a custom header, plus a publisher dispatching events. This is the "roll your own" pattern most companies used before any standardization existed — and it's still perfectly valid for a single internal integration.
Webhook Receiver (Express + TypeScript)
import express, { Request, Response } from 'express';
import crypto from 'crypto';
const app = express();
// Capture raw buffer for cryptographic signature verification
app.use(express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf;
}
}));
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super-secret-key-123';
function verifyHmacSignature(rawBody: Buffer, signatureHeader: string | undefined): boolean {
if (!signatureHeader) return false;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const trustedBuffer = Buffer.from(`sha256=${expectedSignature}`, 'utf8');
const untrustedBuffer = Buffer.from(signatureHeader, 'utf8');
if (trustedBuffer.length !== untrustedBuffer.length) return false;
// Use timingSafeEqual to protect against timing attacks
return crypto.timingSafeEqual(trustedBuffer, untrustedBuffer);
}
app.post('/webhooks/payment-events', (req: Request, res: Response) => {
const signature = req.headers['x-signature-256'] as string;
const rawBody = (req as any).rawBody;
if (!verifyHmacSignature(rawBody, signature)) {
console.error('❌ Invalid Webhook Signature. Rejecting payload.');
return res.status(401).json({ error: 'Invalid cryptographic signature' });
}
const event = req.body;
console.log(`✅ Valid Webhook Received: ${event.eventType}`, event.data);
// Perform background task asynchronously or enqueue to SQS/Redis
// Acknowledge immediately with 200 OK
return res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('Webhook receiver running on port 3000'));
Webhook Publisher Dispatch Logic
import axios from 'axios';
import crypto from 'crypto';
interface WebhookEvent {
id: string;
eventType: string;
timestamp: number;
data: Record<string, any>;
}
async function dispatchWebhook(targetUrl: string, secret: string, payload: WebhookEvent) {
const jsonBody = JSON.stringify(payload);
// Calculate SHA256 HMAC Signature
const signature = crypto
.createHmac('sha256', secret)
.update(jsonBody)
.digest('hex');
try {
const response = await axios.post(targetUrl, jsonBody, {
headers: {
'Content-Type': 'application/json',
'X-Signature-256': `sha256=${signature}`,
'User-Agent': 'MyApp-Webhook-Dispatcher/1.0',
},
timeout: 5000, // 5-second timeout safeguard
});
console.log(`Webhook delivered successfully. Status: ${response.status}`);
} catch (error: any) {
console.error(`Webhook delivery failed: ${error.message}. Schedule retry in worker queue.`);
// Push to retry queue with exponential backoff
}
}
3. The Standardized Alternative: Verifying with the Standard Webhooks Spec
If you're building a new webhook integration in 2026, it's worth signing with the open Standard Webhooks specification instead of a bespoke header. It's a small but meaningful upgrade: instead of a single X-Signature-256 header, the receiver gets three standardized headers — webhook-id, webhook-timestamp, and webhook-signature — and the signed content is {webhook-id}.{webhook-timestamp}.{raw-body}, hashed with HMAC-SHA256 and base64-encoded with a v1, prefix. Verification libraries also enforce a timestamp tolerance (5 minutes by default) to reject replayed requests. Using a maintained SDK instead of hand-rolled crypto.createHmac calls means one less place to get constant-time comparison or replay protection wrong:
import express from 'express';
import { Webhook } from 'standardwebhooks';
const app = express();
app.use(express.raw({ type: 'application/json' }));
const wh = new Webhook(process.env.WEBHOOK_SECRET!); // e.g. "whsec_..."
app.post('/webhooks/orders', (req, res) => {
try {
const event = wh.verify(req.body, {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
});
console.log('✅ Verified event:', event);
res.status(200).json({ received: true });
} catch (err) {
console.error('❌ Signature verification failed');
res.status(401).json({ error: 'Invalid signature' });
}
});
Apollo Server & Webhooks: Bridging the Gap
A common point of confusion is whether Apollo Server and webhooks can be combined into a single event architecture.
In enterprise environments, Apollo Server often acts as a GraphQL Gateway or BFF (Backend-For-Frontend). In this role, it receives inbound webhooks from external SaaS vendors (Stripe, GitHub, Shopify) and relays those updates to frontend clients via GraphQL Subscriptions.
+-------------------+ HTTP POST Webhook +-----------------------------+
| External SaaS | -----------------------------------> | Inbound Webhook Endpoint |
| (Stripe / GitHub) | | (Express / Serverless API) |
+-------------------+ +-----------------------------+
|
| Triggers Event
v
+-------------------+ WebSocket / SSE Subscription +-----------------------------+
| Frontend Client | <----------------------------------- | Redis Pub/Sub Engine |
| (React/Apollo) | | & Apollo Server Gateway |
+-------------------+ +-----------------------------+
Pattern: Inbound Webhook to GraphQL Subscription Relay
- Ingestion endpoint: An Express endpoint or serverless function dedicated to handling incoming third-party webhooks (e.g.,
/api/webhooks/stripe). - Payload validation: Authenticate the incoming request signature using the third party's secret.
- Publish to message bus: Push the validated event into a shared pub/sub channel (Redis, AWS EventBridge).
- Broadcast via subscriptions: The Apollo Server instance listening to that channel triggers a GraphQL Subscription update, pushing refined data to connected browser clients.
This bridge pattern plays to each protocol's strengths: webhooks give you a resilient server-to-server integration boundary; subscriptions give you clean, client-driven real-time updates for active frontend sessions.
If You're on Apollo Federation / GraphOS
If your subscriptions need to span a federated graph, there are two things worth knowing that changed the picture recently:
- Federation version matters. Subscription operations require Apollo Federation 2.4 or later in your subgraph schemas — earlier Federation versions don't support them at all.
- The router talks two different protocols. The GraphOS Router communicates with your subgraphs using the
graphql-transport-wsWebSocket protocol, but it typically serves clients over multipart HTTP responses rather than a client-facing WebSocket — so browser clients don't need a WebSocket library at all in that setup. - Cloud routers are being retired. Apollo is discontinuing its GraphOS Serverless and Dedicated cloud-router plans (Serverless after February 1, 2026; Dedicated after March 15, 2026). If you're relying on a cloud router for subscription support, plan a migration to a self-hosted router well ahead of those dates.
What's Changed Heading Into 2026
A few developments are worth folding into how you think about this decision today:
- The WebSocket transport question is settled — mostly.
graphql-wsis the de facto standard for GraphQL-over-WebSocket;subscriptions-transport-wsis archived and shouldn't be used in new projects. Some GraphQL federation runtimes (WunderGraph Cosmo, for example) now support WebSockets, Server-Sent Events, and multipart HTTP as interchangeable subscription transports, treating the choice as a deployment detail rather than a schema-level commitment. - Serverless-native real-time is real now, not just a workaround. AWS AppSync Events (GA since March 2025) gives teams a managed WebSocket pub/sub layer without operating their own connection state, closing much of the historical gap between "needs persistent connections" and "wants to run on Lambda."
- Webhooks finally have a real standard. The Standard Webhooks specification — driven by Svix with a steering group that includes Zapier, Twilio, ngrok, and Supabase — defines the
webhook-id/webhook-timestamp/webhook-signatureheader scheme described above. It's been adopted well beyond its original backers: OpenAI, Anthropic, and Google Gemini all sign their webhooks this way, alongside smaller platforms like Clerk and GrowthBook. If you're building a webhook publisher today, adopting the spec (or one of its open-source SDKs) instead of inventing your own header format is close to a free win for interoperability. - Webhook infrastructure is now its own category. Dedicated platforms — Svix (outbound, multi-tenant), Hookdeck (inbound routing, replay, fan-out), and the open-source, self-hostable Convoy — exist specifically so teams don't have to build retry queues and dead-letter handling from scratch.
Architectural Decision Matrix
Choose GraphQL Subscriptions when:
- The consumer is a browser or mobile app: You need live UI updates — chat, collaborative editing, live tickers, notification bells.
- Clients need payload granularity: Bandwidth-constrained clients benefit from field-level selection instead of a fixed JSON blob.
- You already run a GraphQL ecosystem: Apollo Client, Relay, or Urql can ingest subscription updates directly into their existing cache/state layers.
- Connection count is predictable: Concurrent connections fit your server's memory budget, or you're using a managed real-time platform (AppSync Events, Ably, Pusher) instead of self-hosting sockets.
Choose Webhooks when:
- Communication is backend-to-backend: Connecting microservices, integrating third-party SaaS, or triggering CI/CD.
- You run on serverless/edge compute: Lambda, Vercel, or Cloudflare Workers, where persistent connections are impractical.
- Guaranteed delivery matters more than instant delivery: Combined with a queue, webhooks give you at-least-once delivery even across receiver downtime.
- You need to absorb bursty traffic: Receivers can buffer into a background queue (BullMQ, SQS) to smooth spikes without dropping connections.
Frequently Asked Questions (FAQ)
1. Is GraphQL over Server-Sent Events (SSE) better than WebSockets?
For many web applications, yes, in specific ways. SSE runs over standard HTTP, making it easier to route through corporate firewalls, proxies, and load balancers, and the browser's EventSource API handles reconnection automatically. Its trade-off is that SSE is unidirectional (server-to-client only), while WebSockets support bidirectional messages over one connection. In practice, several GraphQL runtimes now let you choose either transport for the same schema rather than forcing a single choice up front.
2. How do you handle authentication in GraphQL Subscriptions?
Because standard browser WebSocket APIs don't support custom HTTP headers during the connection upgrade, auth is usually handled inside the protocol handshake payload. With graphql-ws, the client sends an auth token (a JWT, say) inside connectionParams during connection initialization, and the server validates it in the onConnect callback before accepting any subscription requests.
3. Can I use GraphQL Subscriptions for microservice-to-microservice communication?
Technically possible, but it's generally considered an anti-pattern. Persistent WebSockets between backend nodes add coupling and connection-drop failure modes that internal services don't need. For internal sync, reach for webhooks, gRPC, or an event stream like Kafka or EventBridge instead.
4. What happens if a receiver is down when a webhook fires?
A well-designed publisher queues the event and retries with exponential backoff rather than dropping it. Stripe is a useful concrete example: on a delivery failure it retries immediately, then again after roughly 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours, then every 12 hours after that, for up to 3 days total — after which it disables the endpoint and notifies you. Exact schedules vary by provider, but "retry with growing delays, then eventually dead-letter or disable" is the near-universal pattern.
5. What is the Standard Webhooks specification, and should I use it?
It's an open specification for signing and structuring webhook deliveries consistently across providers, so a receiver can verify signatures from any conforming sender using one SDK instead of custom logic per integration. It defines three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 signing scheme, and built-in replay protection via timestamp tolerance. It's worth adopting for new webhook publishers — it costs little and several major API providers already sign this way.
Conclusion
Choosing between GraphQL Subscriptions and Webhooks isn't about which technology is newer or "better" — it's about which protocol matches your system's architecture and network boundaries.
- Use GraphQL Subscriptions for real-time, client-facing experiences over WebSockets or SSE, where you want field-level query control and tight integration with your existing GraphQL client cache.
- Use Webhooks as the backbone for scalable, resilient backend-to-backend integrations, backed by stateless HTTP, cryptographic signatures (ideally Standard Webhooks in new builds), and durable retry infrastructure.
Used together — subscriptions for the last mile to the browser, webhooks for everything crossing a system boundary — you get an event-driven architecture that's scalable, cost-effective, and easier to reason about than forcing one pattern to do both jobs.