Building realtime apps with Server-Sent Events and GraphQL

Live Queries are now deprecated.

As frontend developers, we constantly strive to enhance user experiences by providing realtime updates and dynamic content. One powerful technology that enables this is Server-Sent Events (SSE). When combined with the benefits of GraphQL, SSE becomes an even more valuable tool for building responsive and interactive web applications. In this post, we will explore how we can leverage SSE with GraphQL to create efficient, realtime communication between the server and the client.

Server-Sent Events (SSE) is a technology that enables a client application(s) to automatically receive notifications, messages, or event streams from the server once an initial connection has been established. It's a server push technology that allows client apps to receive data transmission from the server via an HTTP connection and describes how servers can stream data to the client once an initial connection has been established.

One of the reasons SSE is not widely used is that it sits in the middle of the spectrum between WebSockets and HTTP polling. Let's see how it compares to these two technologies.

  • HTTP polling is a technique where the client sends a request to the server at regular intervals to check for updates. This is a simple technique, but it is inefficient because it requires sending unnecessary requests to the server, even when there are no new data available. This can lead to greater network traffic and server load.

HTTP Polling Diagram

  • WebSockets is a bidirectional communication protocol that allows the client and the server to send data to each other at the same time. It also uses a single TCP connection for both sending and receiving data, which makes it more efficient. However, it is complex to implement and requires the client to repeatedly initiate a connection to the server.

WebSockets Diagram

SSE is a better alternative to HTTP polling and WebSockets when you need a lightweight and efficient mechanism for delivering realtime updates to the client in a server-initiated manner. It can be implemented using native APIs (EventSource API or Fetch API) without requiring complicated setup on the client, and it uses a single TCP connection for sending data, similar to WebSockets.

SSE Diagram

Due to its simplicity and the limitations mentioned above, SSE is commonly used to implement the following use cases:

  • Live Feeds and Dashboards: applications that display realtime data updates, such as social media feeds, news tickers, or dashboard monitoring systems
  • Chat and Messaging: applications that require a seamless and interactive chat experience when messages are delivered to clients instantly
  • Collaboration: applications that allow multiple users to edit the same document simultaneously
  • Analytics and Monitoring: applications that require realtime monitoring of data, such as tracking website visitors, monitoring system metrics, or visualizing live data updates
  • Notifications and Alerts: an e-commerce application can use SSE to notify users about order status updates, special promotions, or stock availability changes
  • Stock Tickers and Finance: realtime stock prices, market data, and portfolio changes, ensuring that users have the most up-to-date information on their screens without the need for manual refreshing
  • Gaming: multiplayer games or betting platforms could leverage realtime to deliver a more interactive and engaging experience to users
  • Logs: server monitoring tools or system debugging interfaces, which can be used to promptly identify issues and take immediate actions

These are just a few examples, but SSE can be applied to a wide range of applications that require realtime updates and event-driven communication between the server and the client.

In this section, we will explore how to use SSE by setting up a simple Node.js server and connecting to it from a browser using the EventSource API. We will experiment with SSEs and see how they work in practice.

The EventSource API is a simple interface for opening an HTTP connection to receive push notifications from a server in the form of DOM events. It is part of the HTML5 specification and is supported by all modern browsers.

Firefox 6+, Google Chrome 6+, Opera 11.5+, Safari 5+, Microsoft Edge 79+ (source)

We are going to use this API to connect to our Node.js server and receive SSEs.

Let's first look at the syntax for creating an EventSource object:

const source = new EventSource('/events')

When a cross-domain URL is passed as the URL, you can specify a second parameter with the withCredentials property to indicate whether to send the cookie and authentication headers as shown below:

const source = new EventSource('https://api.example.com/events', {
  withCredentials: true,
})

By default, there are three event types:

  • open indicates a successful connection between the server and the client
  • error handles an error connection between the server and the client
  • message is used to listen to event stream data emitted by the server after a successful connection

Let's look at an example of how to use the EventSource API to connect to our Node.js server and receive SSEs:

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)
}

When messages are sent from the server with an event field, you can attach a handler with the event name as shown below:

source.addEventListener('ping', e => {
  console.log(e.data)
})

We are using the EventSource API to connect to our Node.js server and receive SSEs, but we haven't implemented the server yet. Let's do that next.

Once you have installed Node.js, create a file named app.js containing the following contents:

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}/`)
})

The code above is a simple Node.js server, the important parts are the sendSSE and constructSSE functions, which are responsible for sending special HTTP headers in correct format to the client.

  1. The following HTTP headers are required to establish the SSE connection:
  • Content-Type: text/event-stream
  • Cache-Control: no-cache
  • Connection: keep-alive
  1. SSEs have to be UTF-8 encoded and the data is sent in the following format:
  • id: <message_id> (optional) - indicates the message id
  • event: <event_name> (optional) - indicates the event name
  • retry: <milliseconds> (optional) - indicates how long the browser should wait before trying to reconnect to the server if the connection is lost
  • data: <data> (required) - indicates the data to be sent to the client
  • \n\n (required) - indicates the end of the message

Now create a file named index.html containing the following contents:

<!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>

Time to run your web server using node app.js. Visit http://localhost:8080 and you will see a simple web page with a new timestamp appearing every 5 seconds.

Open the developer tools in Chrome, go to the Network tab, select the file that is sending the server-sent events (/events), and view the received messages in the EventStream tab on the right-hand panel.

Inspecting server-sent events in the browser

While SSE offers a powerful mechanism for realtime communication between the server and the client, there are certain challenges and considerations that frontend developers need to be aware of when implementing it in production. In the following section, we are going to talk about some of them so that we can leverage the full potential of SSE for creating robust and responsive applications.

That means that the client cannot send data to the server. However, this could be seen as beneficial, as the client is no longer able to send arbitrary data to the server. If the client needs to send data to the server, it will have to use another technique such as AJAX, WebSockets, or GraphQL mutations.

The number of concurrent connections is limited by the browser per domain. This means that if you have 6 SSE connections open for one domain, you won't be able to open any more connections until one of the existing connections is closed.

This limitation can be overcome by using HTTP/2 (widely used nowadays) or HTTP/3, which allow for multiplexing multiple requests over a single TCP connection and thus increase the number of concurrent connections up to 100.

SSE only supports text data, which means that you cannot send binary data such as images or audio files. However, you can encode binary data using Base64 and send it as text.

The EventSource API, which is used to establish SSE connections on the client, has certain limitations that you should be aware of.

Firstly, the API lacks support for customizing the HTTP request, meaning that you cannot provide custom headers, cookies, or change the request method. Additionally, there are no native methods for managing connection strategy, which means that you will have to implement your own logic for reconnecting and recovering from connection interruptions.

Another limitation is the lack of native error handling. The EventSource error event handler will tell you nothing about why the request failed nor provide any information about the status code, message, or body of the response. You have to implement your own error handling logic to handle these cases as well.

Furthermore, the EventSource API primarily supports text-based data formats like plain text or JSON, making it less suitable for applications requiring binary data or custom serialization.

Despite these limitations, the EventSource API remains a useful tool for implementing SSE and enabling realtime updates in web applications. And if you need more control over the connection or you want to use SSE in a different environment like Node.js or Deno, you can always use a library.

SSE delegates the bulk of the work to the backend, which means that you will have to implement the SSE protocol on the server-side. This can be challenging if you are new to SSE or have limited experience with backend development.

Firstly, the server needs to maintain open connections with multiple clients and ensure that messages are sent to the appropriate clients without mixing them up. Additionally, error handling, reconnection logic, and data serialization are all handled by the server, which can be difficult to implement correctly.

Another consideration is scaling the SSE implementation to handle high traffic and large numbers of concurrent clients. If thousands of users are all subscribed to the same data and it is updated, you can overload your database by firing thousands of SQL queries at the same time. That's why load balancing and optimizing server resources become crucial to ensure the smooth functioning of SSE.

Overall, while implementing SSE on the backend may introduce some complexities, careful planning and robust architecture can help overcome these challenges and deliver a seamless realtime experience to the clients.

Alternatively, you can use a third-party service such as Grafbase to implement SSE on the backend and focus on building the frontend.

Combining SSE with GraphQL provides frontend developers with a powerful approach to building realtime applications that offer great usability and performance. In this section, we will explore Live Queries and how they can be used with your favorite GraphQL tools to seamlessly integrate SSE on the client. Taking advantage of Grafbase as a backend to use its modern and user-friendly API, we will demonstrate how to build highly interactive and dynamic applications.

Live Queries is a GraphQL feature that allows clients to subscribe to and receive updates in realtime. This is achieved by using SSE to send updates to the client whenever the data changes on the server. From the frontend perspective, it looks like a regular GraphQL query with the @live directive appended to it.

query Todos @live {
  todoCollection(first: 10) {
    edges {
      node {
        id
        title
      }
    }
  }
}

This means that no additional setup or configuration is needed client-side. The client can simply subscribe to the changes by adding the @live directive to the query, and the server will take care of the rest.

However, what will happen if the server sends the full data every time it changes? This will result in a lot of unnecessary data being transferred over the network and can lead to performance issues. To avoid this, Live Queries use partial updates to send only the fields that have changed instead of the full data, along with instructions on how to apply these changes to the existing data on the client.

  • 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
}

The above example utilizes JSON patch, a standard format for describing changes in JSON documents. It contains the path to the field that has changed, the new value of the field, and the operation that should be performed on the field. In this case, the operation is add, which means that the field should be added to the document.

The client then has to apply these changes to the existing data to keep it up-to-date with the latest changes. This can be done by using a library such as json-patch or jsondiffpatch, or by implementing the logic manually (an example of implementation is available).

Looks straightforward, right? Let's build a simple Todo application using Live Queries and Grafbase CLI as a backend. The application will allow us to create todos and see them automatically updated when a new one is added.

Run the following command to initiate a new Grafbase project:

npx grafbase init --template todo

Now create a file named app.js containing the following contents:

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 = ''
  })
}

And create a file named index.html containing the following contents:

<!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>

Now, run your web server using npx vite. Visit http://localhost:5173 and you will see your Todo app. Try adding a new todo and you will see it appear in the list automatically.

Refactor using GraphQL Client (Apollo, Relay, Urql)

The above example is a bit verbose and does not use any frontend or GraphQL libraries, which is not realistic for a real-world application. Therefore, we have prepared examples to bootstrap your next application using the following GraphQL clients:

SSE with GraphQL presents frontend developers with a powerful combination for building responsive and realtime web applications. By leveraging SSE's one-way communication and GraphQL's efficient data fetching capabilities, developers can create seamless user experiences that keep clients up-to-date with the latest information from the server. With careful implementation and consideration of challenges, SSE with GraphQL can unlock a new level of interactivity and engagement in modern web applications.

Get Started

Start building your backend of the future now.