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
-
Protocol:
- SSE uses a standard HTTP connection (typically GET requests).
- The
Content-Type
for SSE responses must betext/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.
-
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.
- Events are sent as plain text in the following structure:
-
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 aLast-Event-ID
header in subsequent requests.
-
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.