Building realtime apps with Server-Sent Events and GraphQL
Server-Sent Events (SSE) push notifications, messages, or event streams from the server to a client after the client opens an HTTP connection. With GraphQL, that stream can carry query results and later patches, so the client stays current without polling.
What is SSE?
Server-Sent Events (SSE) is a server-push mechanism. A client application receives notifications, messages, or an event stream from the server once the initial connection is open. The server then writes data to that HTTP connection.
How does it compare to other realtime communication technologies?
SSE sits between WebSockets and HTTP polling.
- HTTP polling is a technique where the client sends a request to the server at regular intervals to check for updates. It is simple, and inefficient: the client keeps sending requests even when there is no new data, which increases network traffic and server load.
- WebSockets is a bidirectional protocol. The client and the server can send data to each other at the same time over a single TCP connection, which is efficient. It is more complex to implement than SSE.
SSE fits when you need a lightweight, server-initiated stream of realtime updates. It can be implemented with native APIs (the EventSource API or the Fetch API) and uses a single TCP connection for sending data, similar to WebSockets.
What can you build with SSE?
SSE is commonly used for:
- Live feeds and dashboards: social media feeds, news tickers, or monitoring dashboards
- Chat and messaging: delivering messages to clients as they are sent
- Collaboration: multiple users editing the same document
- Analytics and monitoring: website visitors, system metrics, or other live data
- Notifications and alerts: order status, promotions, or stock changes
- Stock tickers and finance: prices, market data, and portfolio changes without a manual refresh
- Gaming: multiplayer games or betting platforms that need a live stream of events
- Logs: server monitoring or debugging interfaces that surface new log lines immediately
The same pattern applies to any app that needs event-driven updates from the server to the client.
How to implement SSE
A small Node.js server can emit events, and the browser can consume them with the EventSource API.
EventSource API
The EventSource API opens an HTTP connection and exposes server push as DOM events. It is part of the HTML specification. Browser support: Firefox 6+, Google Chrome 6+, Opera 11.5+, Safari 5+, Microsoft Edge 79+ (caniuse).
Create an EventSource with the stream URL:
const source = new EventSource('/events')
For a cross-origin URL, a second argument can set withCredentials so the browser sends cookies and authentication headers:
const source = new EventSource('https://api.example.com/events', {
withCredentials: true,
})
Three event types are built in:
openindicates a successful connection between the server and the clienterrorfires when the connection failsmessagereceives event-stream data after a successful connection
const source = new EventSource('/events')
source.onopen = (e) => {
console.log('Connection opened')
}
source.onerror = (e) => {
console.log('Connection failed')
}
source.onmessage = (e) => {
console.log(e.data)
}
If the server sets an event field, listen for that name:
source.addEventListener('ping', (e) => {
console.log(e.data)
})
Setting up a Node.js server
Create app.js:
const http = require('http')
const fs = require('fs')
const sendSSE = (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const id = new Date().toLocaleTimeString()
setInterval(() => {
constructSSE(res, id, new Date().toLocaleTimeString())
}, 5000)
constructSSE(res, id, new Date().toLocaleTimeString())
}
const constructSSE = (res, id, data) => {
res.write(`id: ${id}\n`)
res.write(`data: ${data}\n\n`)
}
const server = http.createServer((req, res) => {
if (req.headers.accept && req.headers.accept == 'text/event-stream') {
if (req.url == '/events') {
sendSSE(req, res)
} else {
res.writeHead(404)
res.end()
}
} else {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.write(fs.readFileSync(__dirname + '/index.html'))
res.end()
}
})
const hostname = '127.0.0.1'
const port = 8080
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
sendSSE and constructSSE set the response headers and write the event-stream body.
- These HTTP headers establish the SSE connection:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
- Events are UTF-8 text in this format:
id: <message_id>(optional) identifies the messageevent: <event_name>(optional) sets the event nameretry: <milliseconds>(optional) tells the browser how long to wait before reconnectingdata: <data>(required) is the payload- a blank line (
\n\n) ends the message
Create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE Node.js Example</title>
</head>
<body>
<script type="text/javascript">
const source = new EventSource('/events')
source.onopen = (e) => {
document.body.innerHTML += 'Connection opened<br>'
}
source.onerror = (e) => {
document.body.innerHTML += 'Connection failed<br>'
}
source.onmessage = (e) => {
if (e.readyState == EventSource.CLOSED) {
document.body.innerHTML += 'Connection closed<br>'
} else {
document.body.innerHTML += e.data + '<br>'
}
}
</script>
</body>
</html>
Run node app.js and open http://localhost:8080. A new timestamp appears every 5 seconds.
How to inspect SSE events in the browser
In Chrome DevTools, open the Network tab, select the request that sends the events (/events), and read the messages in the EventStream panel.
Caveats
SSE is unidirectional
The client cannot send data on the SSE connection. If the client needs to send data, use another channel such as fetch, WebSockets, or a GraphQL mutation.
Six concurrent connections on HTTP/1.1
Browsers limit the number of concurrent HTTP/1.1 connections per domain, commonly to 6. A seventh SSE connection waits until one of the existing connections closes.
HTTP/2 (widely used) and HTTP/3 multiplex many streams on one connection and raise that limit. A common HTTP/2 concurrent-stream cap is 100.
No binary data
SSE carries text. Binary payloads such as images or audio have to be encoded, for example with Base64, and sent as text.
EventSource API limits
The EventSource API does not let you set custom headers, attach cookies beyond withCredentials, or change the HTTP method. There is no built-in hook for a custom reconnect strategy, so recovery after a dropped connection is application code.
The error handler does not report why the request failed. It does not expose the status code, message, or body. That handling has to be written separately.
The API is aimed at text formats such as plain text or JSON.
For more control over the request, or for SSE from Node.js or Deno, use a library or fetch with a readable stream instead of EventSource.
Backend complexity
The server implements the protocol. It keeps connections open, routes each event to the right clients, and handles errors, reconnects, and serialization.
Scaling is the sharper constraint. If thousands of clients subscribe to the same data, a naive implementation can run a query per subscriber on every change and overload the database. Load balancing and sharing one query result across subscribers matter more than the wire format.
GraphQL Live Queries
A GraphQL live query uses SSE to push updates when server data changes. On the client it is an ordinary query with an @live directive:
query Todos @live {
todoCollection(first: 10) {
edges {
node {
id
title
}
}
}
}
The client subscribes by adding @live. The server sends the stream.
Sending the full result on every change wastes bandwidth. Live queries avoid that by sending the initial result, then partial updates: only the fields that changed, plus instructions for applying them.
Initial result:
{
"data": {
"todoCollection": {
"edges": [
{
"node": {
"id": "todo_01H28FZ6R8PNC81VVZJQMBZ45Y",
"title": "Learn SSE",
"completed": true
}
},
{
"node": {
"id": "todo_01H28G4TMEFXX786QYMG9RBSKD",
"title": "Learn Live Queries",
"completed": false
}
}
]
}
}
}
Partial update (JSON Patch):
{
"patch": [
{
"op": "add",
"path": "/todoCollection/edges/3",
"value": {
"node": {
"id": "todo_01H28G4TMEFXX786QYMG9RBSKD",
"title": "Learn Live Queries",
"completed": true
}
}
}
],
"revision": 1
}
Each patch operation names a path, a new value, and an op. Here add inserts a value into the document. The client applies the patch to the result it already holds, using a library such as json-patch or jsondiffpatch, or its own code (an example).
Todo app with live queries
This example creates todos and appends them to a list when the live query reports a change. The backend is a Grafbase dev server started from the todo template:
npx grafbase init --template todo
app.js opens the live query as an EventSource and posts a mutation with fetch:
const url = 'http://127.0.0.1:4000/graphql'
const query = /* GraphQL */ `
query Todos @live {
todoCollection(first: 100) {
edges {
node {
id
title
}
}
}
}
`
const eventSource = new EventSource(`${url}?query=${encodeURIComponent(query)}`)
eventSource.onmessage = (message) => {
const data = JSON.parse(message.data)
if (data.patch) {
const todos = data.patch
.map((patch) => `<li>${patch.value.node.title}</li>`)
.join('')
document.getElementById('todos').innerHTML += todos
}
if (data.data) {
const todos = data.data.todoCollection.edges
?.map((edge) => `<li>${edge.node.title}</li>`)
.join('')
document.getElementById('todos').innerHTML = todos
}
}
document.getElementById('form').onsubmit = (event) => {
event.preventDefault()
const title = document.getElementById('title').value
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation TodoCreate {
todoCreate(input: { title: "${title}" }) {
__typename
}
}`,
}),
}).then(() => {
document.getElementById('title').value = ''
})
}
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Todo App</title>
</head>
<body>
<h1>Todo App</h1>
<form id="form">
<input type="text" id="title" placeholder="Title" />
<button type="submit">Add</button>
</form>
<ul id="todos"></ul>
<script src="app.js"></script>
</body>
</html>
Serve the page with npx vite and open http://localhost:5173. Adding a todo updates the list from the event stream.
The same live query can sit behind a GraphQL client. Apollo, Urql, and Relay can own the cache update instead of writing DOM nodes from onmessage.
Conclusion
SSE is a one-way HTTP stream. A GraphQL live query uses that stream for an initial result and later JSON Patch updates, so the client tracks server data without refetching the whole query. The hard parts are connection limits, text-only payloads, thin error reporting in EventSource, and not fanning one data change out into one database query per subscriber.