Building realtime apps with Server-Sent Events and GraphQL
Server-Sent Events (SSE) keeps a browser updated over a long-lived HTTP response. Unlike polling, the server sends new data as soon as it has it. This post builds a small SSE endpoint, walks through its trade-offs, and then uses it to carry GraphQL live-query updates.
What is SSE?
The client opens an ordinary HTTP request, and the server never finishes the response. Instead, it keeps writing events to it, and the browser hands each one to your code as it arrives.
How does it compare to polling and WebSocket?
Polling asks the server over and over whether anything changed, which costs requests even when nothing did. WebSocket opens a bidirectional channel, which is powerful but needs its own protocol and server support. SSE sits in the simpler middle: the server pushes text over plain HTTP, and the client uses separate requests when it needs to send something back. Browsers support it natively through the EventSource API, and fetch with a readable stream works too.
What can you build with SSE?
SSE works best when updates flow mostly from server to client:
- Feeds and dashboards: news tickers, prices, system metrics
- Notifications: order status, alerts, mentions
- Logs and monitoring: new log lines or build output as it happens
- Server-driven updates in apps that are otherwise request-based
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 and works in every modern browser (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 includes cookies:
const source = new EventSource('https://api.example.com/events', {
withCredentials: true,
})
Most clients only need three handlers:
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 (source.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 /events request, 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 one has to live in application code.
The error handler does not report why the request failed. It does not expose the status code, message, or body, so those have to be read another way.
The API is aimed at text formats such as plain text or JSON.
Reach for fetch with a readable stream, or a library built on it, when you need custom headers, a different HTTP method, control over reconnection, or SSE outside the browser.
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
SSE only defines how updates travel. A live query defines which data should stay current and what shape the updates take.
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
completed
}
}
}
}
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/2",
"value": {
"node": {
"id": "todo_01H28GAZ3Q9V1KJ8N5XTWRDPME",
"title": "Ship Live Queries",
"completed": false
}
}
}
],
"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, and a GraphQL live query uses it to send an initial result followed by JSON Patch updates. The transport is the easy part. The real design problem is deciding which subscribers need an update and producing that update once, not once per subscriber.