An intro to server sent events

header image

Server sent events allow us to establish a unidirectional communication channel between clients and the server. Over this channel, the server can push events(which are just plain text messages) that the client can receive and process in real time.

We’ll be experimenting with server sent events in this article. You can refer to the github repo here.

In order to use server sent events, we need to establish a long lived HTTP connection from the client with the server. The client must accept text/event-stream in response to this request. Remember that we don’t want the server to send back the response immediately because the connection would be closed in that case. This will be clear when we look at an example in node.js.

If you’ve heard of websockets, SSE is very similar to that. There are a few key differences though.

  1. Websockets are bidirectional, SSE is unidirectional.
  2. Websockets can send binary or plain text messages. SSE only supports plain text messages.
  3. Both are supported in almost all major browsers. Websockets have received more attention though.
  4. Websockets are lower level and more complex to set up as compared to SSE. There are less pitfalls when using SSE.

Let’s jump into the code. We’ll be using typescript and express js. Start off by installing all the dependencies.

mkdir sse-test && cd sse-test
yarn init -y # init the project
yarn add express nanoid # install deps
yarn add -D @types/express @types/node nodemon typescript # install types

In order for this to be a typescript project, we need to have a tsconfig.json. To generate it easily, run

npx tsconfig.json # then select node and press enter

Make a file src/index.ts and paste the following:

import express from 'express'
import { join } from 'path'

const app = express()
app.use(express.json())

const PORT = +process.env.PORT! || 3000

app.use(express.static(join(__dirname, '../public')))

app.listen(PORT, () => console.log(`> http://localhost:${PORT}`))

Now make a file in the root of the project named public/index.html.

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>SSE App</title>
</head>

<body>
  <p>Home page</p>

  <script>
    console.log('hello world')
  </script>

</body>

</html>

Now open package.json and make the following scripts to easily start the server

// ...
"scripts": {
    "watch": "tsc -w",
    "dev": "nodemon dist/index.js"
},
// ...

Open 2 terminal windows and run yarn watch (leave it running) in one and yarn dev (leave it running as well) in the other window. Open localhost:3000 and you’ll be good to go.
Let’s set up our express route to handle SSE. In the file src/index.ts, paste

app.get('/events', (req, res) => {
  console.log(req.headers)
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Connection': 'keep-alive',
    'Cache-Control': 'no-cache'
  })  
  res.write(`data: “Hello”\n\n`)
  req.on('close', () => {
    console.log(`connection closed by client`)
  })
  // see that we don’t do res.json() or res.send() here
})

As you can see we don’t send a response immediately to the client. We set the content type for the response to be an event stream and the connection header is set to keep alive so that the connection is not terminated.
Event format in SSE follows a specific pattern. It is as follows:

event: <name of your event>\n
data: <string, event data>\n
retry: <in case connection is closed, after how many ms to retry this>\n
id: <id of the message>\n\n

Every message is terminated by \n\n. An event usually has some data associated with it. All other fields are optional.

Now let’s make sure frontend subscribes to this stream and receives events.
In public/index.html

<script>
    const eventSource = new EventSource('/events')
    eventSource.onmessage = event => {
      console.log('Received event', event)
    }
</script>

When you run this, you will see some message on the console like so:

server sent event console

On the server console, you can see the headers sent by the client:

server sent events server console

The EventSource API used in index.html sets the client headers to accept event stream. You can also use the fetch API as well for SSE by manually setting these headers.
Let’s see how we can broadcast messages across multiple clients. Make a file src/types.ts

import { Response } from 'express'

export type CustomEvent = {
  data: Object
  id: string
  type: string
  retry?: number
}
export type Client = {
  id: string
  res: Response
}

Then in src/index.ts

import { CustomEvent, Client } from './types'
import { nanoid } from 'nanoid'

let clients: Client[] = []

app.get('/events', (req, res) => {
  console.log(req.headers)
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Connection': 'keep-alive',
    'Cache-Control': 'no-cache'
  })

  const client: Client = {
    res,
    id: nanoid(10)
  }  
  sendEventToAll({
    type: 'join', // event type is join
    data: {
      joined: client.id
    },
    id: nanoid(10)
  })
  clients.push(client)
  req.on('close', () => {
    console.log(`connection closed by client ${client.id}`)
    clients = clients.filter(c => c.id !== client.id)
  })
})
// send to all connected clients
function sendEventToAll(event: CustomEvent) {
  console.log('sending event', event)
  clients.forEach(c => {
    let eventString = `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n`
    if (event.retry) {
      eventString += `retry: ${event.retry}\n`
    }
    eventString += `id: ${event.id}\n\n`
    c.res.write(eventString)
  })
}

It is pretty self explanatory. We store every client in an array of clients, where every client has an id and an express response handle so that we can write out events to them.
The most notable thing here is the use of custom event names. We specify these in the event: ${event.type} line. To receive custom events on frontend, you can use addEventListener on eventSource object like so:

eventSource.addEventListener('join', event => {
    console.log('Join received', event)
})

Now open two browser windows to see this in action.
The cool thing about SSE is that when you associate an id with every event, the browser automatically sends the last event id to the server in case the connection is dropped by the server. This way, the server can send all remaining messages to the client so it can catch up. To see this in action, open two browser windows again. After that, restart the server by typing rs and then enter in nodemon. As soon as the client reconnects to the server, you’ll see the header last-event-id sent with /events request.

last event id header

You can use SSE in situations when the server needs to communicate to the client in real time. For example, in-app unread notification count, live update of likes/comments on posts, real-time flow of data in admin dashboards etc. However be wary of the biggest limitation of SSE, which is unidirectional communication.

I hope you found this introduction informative. If you have any doubts, feel free to comment below.

Share :
Tags :