You could have invented it yourself: Server-Sent Events

Why do we need Server-Sent Events?

In modern web applications, real-time communication is more important than ever. Features like live updates, collaborative editing, notifications, and data streaming have become standard expectations for users. Before we look at Server-Sent Events, let’s first look at a few alternatives.

Alternatives to SSE

Polling

Polling is the simplest approach to achieve near-real-time updates. The client repeatedly sends HTTP requests to the server at regular intervals to check for changes. While easy to implement, this method is inefficient. It generates unnecessary network traffic and puts extra load on the server, especially when updates are infrequent. The client may poll repeatedly even when no updates are available, resulting in wasted resources.

Long Polling

Long polling improves upon basic polling by keeping the HTTP connection open until the server has an update to send. Once the server responds with data, the connection closes, and the client immediately reopens it to wait for the next update. While this reduces some of the inefficiencies of traditional polling, it still has drawbacks. Opening and closing connections repeatedly can strain resources, and implementing long polling often introduces additional complexity.

WebSockets

WebSockets offer full-duplex communication, enabling the server and client to send messages to each other in real time. While this is powerful, WebSockets can be overkill for many use cases that only require one-way communication from the server to the client. They also add complexity in terms of setup, management, and maintaining stateful connections, which can be challenging in distributed or load-balanced environments.

Why SSE?

SSE provide a lightweight, efficient, and straightforward way for servers to push updates to clients. They rely on a simple HTTP connection, making them easy to implement and compatible with most environments. Unlike WebSockets, SSE is inherently one-directional (server to client), which is ideal for scenarios like live score updates, stock price monitoring, or streaming logs.

With SSE, developers get a solution that’s simple to use, resource-friendly, and aligned with the natural statelessness of HTTP. In many cases, it’s the elegant middle ground between the brute force of polling and the power of WebSockets.

Backend with Spring Boot

The backend streams Server-Sent Events (SSE) using Spring Boot’s SseEmitter class. In general, this should be quite straightforward. We expose an endpoint via /events with an optional count to restrict the number of sent events. Once a client (which supports SSE) connects, we emit JSON-serialized tick events with a unique id and event name.

In a classic REST-based API, one can add this as an extension to the query endpoints. Besides GET /users and GET /users/{id} you would also have GET /users/events and GET /users/{id}/events.

Implementation

 1record Tick(int tick) { }
 2
 3@RestController
 4public class EventController {
 5    private static final Logger log = LoggerFactory.getLogger(EventController.class);
 6    private final ObjectMapper objectMapper;
 7
 8    public EventController(ObjectMapper objectMapper) {
 9        this.objectMapper = objectMapper;
10    }
11
12    @GetMapping("/events")
13    public SseEmitter events(@RequestParam(name = "count", required = false) Integer count) {
14        var max = count == null ? 10 : count;
15        log.info("/events called with count {} and max {}", count, max);
16
17        var emitter = new SseEmitter(60 * 1000L);
18        var id = UUID.randomUUID().toString();
19        Executors.newSingleThreadScheduledExecutor().execute(() -> {
20            try {
21                for (int i = 0; i < max; i++) {
22                    var tick = new Tick(i);
23                    emitter.send(SseEmitter.event()
24                            .name("tick")
25                            .id(id)
26                            .data(objectMapper.writeValueAsString(tick)));
27                    TimeUnit.MILLISECONDS.sleep(500);
28                }
29                // Without a payload, the event is not correctly processed
30                // in the browser. This is actually expected behaviour,
31                // see https://github.com/denoland/deno/issues/23135.
32                emitter.send(SseEmitter.event().name("close").data(""));
33                log.info("Closing connection");
34                emitter.complete();
35            } catch (Exception e) {
36                emitter.completeWithError(e);
37            }
38        });
39
40        return emitter;
41    }
42}

The only annoying trap was figuring out why the close event was never processed by the frontend (see below). If you omit any data, the browser is free to ignore the event…

Testing the endpoint with curl

When using curl, this looks like

 1$ curl http://localhost:9000/events?count=4
 2event:tick
 3id:ba711ed1-bb55-4696-9312-ca4c10725e5a
 4data:{"tick":0}
 5
 6event:tick
 7id:ba711ed1-bb55-4696-9312-ca4c10725e5a
 8data:{"tick":1}
 9
10event:tick
11id:ba711ed1-bb55-4696-9312-ca4c10725e5a
12data:{"tick":2}
13
14event:tick
15id:ba711ed1-bb55-4696-9312-ca4c10725e5a
16data:{"tick":3}
17
18event:close
19data:

A simple user interface

By any means, I am not a frontend software engineer. Nonetheless, here’s a basic, framework-less UI which shows how to use SSE in a client. The key implementation is this JavaScript snippet

 1document.querySelector('form').addEventListener('submit', e => {
 2    e.preventDefault();
 3    const count = document.getElementById('count').value;
 4    document.getElementById('count').value = '';
 5    const eventDisplay = document.getElementById('events');
 6    eventDisplay.innerHTML = '';
 7
 8    if (window.eventSource) window.eventSource.close();
 9    window.eventSource = new EventSource(`/events?count=${count}`);
10
11    eventSource.addEventListener('tick', event => {
12        const p = document.createElement('p');
13        p.textContent = `Tick: ${JSON.parse(event.data).tick + 1}`;
14        eventDisplay.appendChild(p);
15    });
16
17    eventSource.addEventListener('close', () => eventSource.close());
18});

Once we’ve subscribed to the submit event of the form, we use the Browser’s EventSource API to listen to events sent by the server and react accordingly.

If we do not react to a close event by closing our listener, and the server closes its connection on their side, the browser will simply reopen the connection after a few seconds and listen to new events again.

The specification

Server-Sent Events (SSE) are defined as part of the HTML5 specification. They provide a standardized way for servers to push updates to web clients over a persistent HTTP connection using a lightweight, text-based protocol.

Key Characteristics

  1. Protocol:

    • SSE uses a standard HTTP connection (typically GET requests).
    • The Content-Type for SSE responses must be text/event-stream.
    • HTTP headers like Cache-Control: no-cache are commonly used to prevent caching of the event stream by intermediaries.
    • Connections are usually kept open indefinitely, relying on persistent HTTP/1.1 or HTTP/2 features.
  2. Event Format:

    • Events are sent as plain text in the following structure:
      id: <unique-id>
      event: <event-name>
      data: <payload>
    • Each event ends with a blank line to indicate completion.
    • Binary data is not directly supported, i.e., must be encoded with Base64.
  3. Auto-Reconnection:

    • If the connection is lost, the browser automatically attempts to reconnect after a short delay.
    • The id field allows clients to resume from the last event by sending a Last-Event-ID header in subsequent requests.
  4. Character Encoding:

    • SSE requires UTF-8 encoding for all transmitted data.

An exchange with curl

Coming back to our example with curl, we can verify the actual HTTP headers sent in the response.

 1$ curl -v http://localhost:9000/events?count=4
 2* Host localhost:9000 was resolved.
 3* IPv6: ::1
 4* IPv4: 127.0.0.1
 5*   Trying [::1]:9000...
 6* Connected to localhost (::1) port 9000
 7> GET /events?count=4 HTTP/1.1
 8> Host: localhost:9000
 9> User-Agent: curl/8.9.1
10> Accept: */*
11>
12* Request completely sent off
13< HTTP/1.1 200
14< Content-Type: text/event-stream
15< Transfer-Encoding: chunked
16< Date: Sat, 21 Dec 2024 17:32:27 GMT
17<
18event:tick
19id:5d482700-187c-4928-8de5-cca0681f0aac
20data:{"tick":0}

A backend using just the JDK

Looks like Server-Sent Events are not magic at all. While Spring allows us to use them via a comfortable API through the SseEmitter class, we can easily write our own backend implementation – the frontend part is left as an exercise for the reader.

For convinience, we also left out handling of the optional count parameter since it’s not relevant for the protocol and the thread handling. It’s easy to add but would hide the relevant part, the implementation of SSE itself.

 1// ...boring package declaration and imports...
 2
 3record TickEvent(int tick) {
 4   String toJson() {
 5      return "{\"tick\":" + tick + "}";
 6   }
 7}
 8
 9record SseEvent(String id, String event, String data) {
10   String toSseFormat() {
11      // Not beautiful, but good enough.
12      return "id: " + id + "\n" +
13              "event: " + event + "\n" +
14              "data: " + data + "\n\n";
15   }
16}
17
18public class SseServer {
19   public static void main(String[] args) throws Exception {
20      // Create a basic HTTP server which is part of the JDK.
21      HttpServer server = HttpServer.create(new InetSocketAddress(9000), 0);
22      server.createContext("/events", new SseHandler());
23      server.setExecutor(Executors.newCachedThreadPool());
24      server.start();
25   }
26
27   static class SseHandler implements HttpHandler {
28      private final ObjectMapper objectMapper = new ObjectMapper();
29
30      @Override
31      public void handle(HttpExchange exchange) {
32         try {
33            // Set default headers for SSE.
34            exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
35            exchange.getResponseHeaders().set("Cache-Control", "no-cache");
36            exchange.getResponseHeaders().set("Connection", "keep-alive");
37            exchange.sendResponseHeaders(200, 0);
38
39            try (OutputStream os = exchange.getResponseBody()) {
40               var id = UUID.randomUUID().toString();
41               for (int i = 0; i < 10; i++) {
42                  TickEvent tickEvent = new TickEvent(i);
43                  SseEvent sseEvent = new SseEvent(
44                          id,
45                          "tick",
46                          tickEvent.toJson()
47                  );
48
49                  os.write(sseEvent.toSseFormat().getBytes());
50                  os.flush();
51                  Thread.sleep(500);
52               }
53
54               SseEvent closeEvent = new SseEvent("", "close", "");
55               os.write(closeEvent.toSseFormat().getBytes());
56               os.flush();
57            }
58         } catch (Exception e) {
59            // 🙈 ... good enough for us.
60            e.printStackTrace();
61         }
62      }
63   }
64}

Source code

The whole source code can be found on GitHub.