Home | Send Feedback | Share on Bluesky |

Server-Sent Events with Spring

Published: 5. March 2017  •  Updated: 17. March 2026  •  java, spring

A popular choice for sending real-time data from the server to a web application is the WebSocket protocol. WebSocket opens a bidirectional connection between client and server. Both parties can send and receive messages. In scenarios where the application only needs one-way communication from the server to the client, a simpler alternative exists: Server-Sent Events (SSE). It's part of the HTML standard and uses HTTP as the transport protocol. The protocol only supports text messages, and it's unidirectional, meaning only the server can send messages to the client.

With SSE, the server cannot immediately start sending messages to the client. It's always the client (browser) that establishes the connection, and after that, the server can send messages. An SSE connection is a long-streaming HTTP connection.

  1. Client opens the HTTP connection.
  2. Server sends zero, one, or more messages over this connection.
  3. Connection is closed by the server or due to a network error.
  4. Client opens a new HTTP connection, and so on.

The advantage of Server-Sent Events is that they have a built-in reconnection feature when the client loses the connection. It tries to reconnect to the server automatically. WebSocket does not have such built-in functionality.

Even though only the server can send messages to the client over SSE, you can still build applications such as chat systems with this technology, because a client can always open a second HTTP connection with the Fetch API or XMLHttpRequest and send data to the server. If you need full duplex messaging on a single connection, WebSocket is still the better fit.

Client API

The Server-Sent Events API in the browser is straightforward and consists of only one object: EventSource. To open a connection, an application needs to instantiate the object and provide the URL of the server endpoint.

const eventSource = new EventSource('/stream');

The browser immediately sends a GET request to the URL with the Accept header text/event-stream.

GET /stream HTTP/1.1
Accept: text/event-stream
...

Because this is a normal GET request, an application can add query parameters to the URL like with any other GET request.

const eventSource = new EventSource('/stream?event=news');

Query parameters cannot be changed during the lifecycle of the EventSource object. Every time the client reconnects, it uses the same URL. But an application can always close the connection with the close() method and instantiate a new EventSource object.

let eventSource = new EventSource('/stream?event=worldnews');
...
eventSource.close();
eventSource = new EventSource('/stream?event=sports');

The HTTP response to an EventSource GET request must contain the Content-Type header with the value text/event-stream and the response must be encoded with UTF-8.

HTTP/1.1 200
Content-Type: text/event-stream;charset=UTF-8

The SSE protocol is text-based. Each field starts with a keyword, followed by a colon and a string value. The data keyword specifies a message for the client. Spaces before and after the message are ignored. Every line is separated with a carriage return (0d) or a line feed (0a) or both (0d 0a).

data: the server message

The server can split a message over several lines.

data:line1
data:line2
data:line3

The browser concatenates these three lines and emits a single event. To separate the messages from each other, the server needs to send a blank line after each message.

data:{"heap":148713928,"nonHeap":49888752,"ts":1488640735925}

data:{"heap":149344096,"nonHeap":49889392,"ts":1488640736927}

data:{"heap":149344096,"nonHeap":49889392,"ts":1488640736929}

You see that the payload does not have to be a simple string. It's perfectly legal to send JSON strings and parse them on the client with JSON.parse.

To process these events in the browser, an application needs to register a listener for the message event. The property data of the event object contains the message. The browser filters out the keyword data and the colon and only assigns the string after the colon to event.data.

eventSource.onmessage = event => {
  const msg = JSON.parse(event.data);
  // access msg.ts, msg.heap, msg.nonHeap
};

An application can listen for the open and error events. The open event is emitted as soon as the server sends a 200 response back. The error event is fired whenever a network error occurs. It is also emitted when the server closes the connection.

eventSource.onopen = () => console.log('open');
eventSource.onerror = error => {
  if (eventSource.readyState === EventSource.CLOSED) {
    console.log('close');
  }
  else {
    console.log(error);
  }
};

Named Events

A server can assign an event name to a message with the event: keyword. The event: line can precede or follow the data: line. In this example, the server sends 4 messages. The first message is an add event, the second a remove event, then follows an add event again, and the last message is an unnamed event.

event:add
data:100

data:56
event:remove

event:add
data:101

data:simple event

Named events are processed differently on the client. They do not trigger the message handlers. Named events emit an event that has the same name as the event itself. For this example, we need 3 listeners to process all the messages. You cannot use the on... syntax for registering listeners to these events. They have to be registered with the addEventListener function.

eventSource.onmessage = e => {
  // receives: 'simple event'
};
/* OR
eventSource.addEventListener('message', e => {
  // receives: 'simple event'
}, false);
*/

eventSource.addEventListener('add', e => {
  // receives "100" and "101"
}, false);

eventSource.addEventListener('remove', e => {
  // receives "56"
}, false);

Reconnect

Browsers keep the Server-Sent Events HTTP connection open as long as possible. When the connection is closed by the server or due to a network error, the browser waits by default 3 seconds and then opens a new HTTP connection. The browser tries to reconnect forever until it gets a 200 HTTP response back. With a call to close(), an application can stop this.

The server can change the 3 seconds wait time between connections. To change it, the server sends a retry: line together with the message. The number after the colon specifies the number of milliseconds the browser has to wait before it tries to reconnect.

event:add
data:100
retry:10000

After the browser receives this message, it changes the wait time between connections to 10 seconds. With retry:0, the browser immediately tries to reconnect after the previous connection was closed.

ID

The server can assign an id to every message with the id: keyword. Valid values for the id are any arbitrary string.

id:2012-08-19T10:11:20
data:648

A client can access this id with the property lastEventId of the event object.

eventSource.addEventListener('message', e => {
  console.log(e.data); // "648"
  console.log(e.lastEventId); // "2012-08-19T10:11:20"
}, false);

The primary use case for this id is to keep track of what messages the client successfully received. When the SSE connection was closed, the browser sends a new GET request, and in this request, it sends the last received message id as an additional HTTP header Last-Event-ID to the server.

GET /memory HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 2012-08-19T10:11:20

The server can then read this header and send all newly created messages since this id to the client, to make sure that the client receives all messages without any gap.

SSE in Spring

Spring MVC has supported Server-Sent Events since Spring Framework 4.2, and the programming model is still valid today. In the following example, we create a Spring Boot application that sends the current heap and non-heap memory usage of the Java virtual machine to the client as Server-Sent Events. The client is a simple HTML page that displays these values.

If you build a traditional servlet-based application, SseEmitter is still the standard Spring MVC choice. If you build a new reactive application, Spring WebFlux is usually the more natural fit because a controller can return Flux<ServerSentEvent<?>> or Flux<T> directly.

We create the sample application with https://start.spring.io/ and select Spring Web as the dependency.

Next, we create a POJO that holds the memory information.

public class MemoryInfo {
  private final long heap;

  private final long nonHeap;

  private final long ts;

MemoryInfo.java

Then we create a scheduled service that reads the memory information every second, creates an instance of the MemoryInfo class, and publishes it with Spring's event bus infrastructure.

@Service
public class MemoryObserverJob {

  public final ApplicationEventPublisher eventPublisher;

  public MemoryObserverJob(ApplicationEventPublisher eventPublisher) {
    this.eventPublisher = eventPublisher;
  }

  @Scheduled(fixedRate = 1000)
  public void doSomething() {
    MemoryMXBean memBean = ManagementFactory.getMemoryMXBean();
    MemoryUsage heap = memBean.getHeapMemoryUsage();
    MemoryUsage nonHeap = memBean.getNonHeapMemoryUsage();

    MemoryInfo mi = new MemoryInfo(heap.getUsed(), nonHeap.getUsed());
    this.eventPublisher.publishEvent(mi);
  }

MemoryObserverJob.java

Next, we create a RestController that handles the EventSource GET request from the client. The handler returns an instance of SseEmitter. Each client connection is represented by its own SseEmitter. Spring does not provide built-in connection registry management for these emitters, so in this application we store them in a simple list (emitters) and add handlers to the emitter's completion and timeout events to remove them from the list.

@Controller
public class SSEController {

  private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();

  @GetMapping("/memory")
  public SseEmitter handle(HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-store");

    SseEmitter emitter = new SseEmitter();
    // SseEmitter emitter = new SseEmitter(180_000L);

    this.emitters.add(emitter);

    emitter.onCompletion(() -> this.emitters.remove(emitter));
    emitter.onTimeout(() -> this.emitters.remove(emitter));

    return emitter;
  }

  @EventListener
  public void onMemoryInfo(MemoryInfo memoryInfo) {
    List<SseEmitter> deadEmitters = new ArrayList<>();
    this.emitters.forEach(emitter -> {
      try {
        emitter.send(memoryInfo);

        // close connnection, browser automatically reconnects
        // emitter.complete();

        // SseEventBuilder builder = SseEmitter.event().name("second").data("1");
        // SseEventBuilder builder =
        // SseEmitter.event().reconnectTime(10_000L).data(memoryInfo).id("1");
        // emitter.send(builder);
      }
      catch (Exception e) {
        deadEmitters.add(emitter);
      }
    });

    this.emitters.removeAll(deadEmitters);
  }

SSEController.java

In current Spring MVC applications, the default async timeout depends on the underlying servlet container unless you set it explicitly. In practice, setting it explicitly is preferable because it makes the behavior predictable across environments.

An application can configure the timeout in application.properties.

spring.mvc.async.request-timeout=45000

This setting keeps the HTTP connection open for 45 seconds. Alternatively, an application can set the timeout directly in the SseEmitter constructor.

SseEmitter emitter = new SseEmitter(180_000L); //keep connection open for 180 seconds

It's also a good idea to declare the response type explicitly on the mapping.

@GetMapping(path = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handle() {
  ...
}

For production systems, it is also worth sending periodic heartbeat events or comments. This helps detect dead clients earlier and prevents intermediaries such as proxies or load balancers from considering the connection idle.

The method onMemoryInfo is annotated with @EventListener and listens for the events sent from the MemoryObserverJob class. When a new object is emitted, the method loops over all registered clients and tries to send the MemoryInfo object to them. The send call can fail when the client is no longer connected. On the Servlet stack, disconnect detection is still mostly write-driven: the server usually discovers the disconnect only when it tries to write to the response. Because of that, we add a try-catch around the send method, and when sending fails, the application removes that client from the emitters list.

To send messages to the client, the application calls the emitter's send method. This method expects either an object or an SseEmitter.SseEventBuilder. Objects are converted to JSON and sent in a data: line to the client. The SseEventBuilder allows the application to set all the previously mentioned message attributes like retry, id, and event name. The static event() method of the SseEmitter class creates a new SseEventBuilder.

SseEventBuilder builder = SseEmitter.event()
                                    .data(memoryInfo)
                                    .id("1")
                                    .name("eventName")
                                    .reconnectTime(10_000L);
emitter.send(builder);

Every method of the builder corresponds to a keyword in the SSE message:

data(...) -> data:
id(...) -> id:
name(...) -> event:
reconnectTime(...) -> retry:

Spring provides an easy way to access the Last-Event-ID HTTP header when the application needs it. You have to make the parameter optional with required=false because the initial GET request from the client does not contain this HTTP header.

@GetMapping("/memory")
public SseEmitter handle(@RequestHeader(name = "Last-Event-ID", required = false) String lastId) {
  ...
}

The client in our example opens the SSE connection with

const eventSource = new EventSource('/memory');

and registers a message listener that parses the JSON and updates three DOM elements to display the received data.

<!DOCTYPE html>
<html>
<head>
<title>Server Memory Monitor</title>
<script>
function initialize() {
  const eventSource = new EventSource('/memory');
  const timestampElement = document.getElementById('timestamp');
  const heapElement = document.getElementById('heap');
  const nonHeapElement = document.getElementById('nonheap');

  eventSource.onmessage = e => {
    const msg = JSON.parse(e.data);
    timestampElement.textContent = new Date(msg.ts);
    heapElement.textContent = msg.heap;
    nonHeapElement.textContent = msg.nonHeap;
  };
  
  eventSource.onopen = () => console.log('open');

  eventSource.onerror = error => {
    if (eventSource.readyState === EventSource.CLOSED) {
      console.log('close');
    }
    else {
      console.log(error);
    }
  };
  
  eventSource.addEventListener('second', (e => console.log('second', e.data)), false);  
}

window.onload = initialize;
</script>
</head>
<body>
  <h1>Memory Observer</h1>

  <h3>Timestamp</h3>
  <div id="timestamp"></div>

  <h3>Heap Memory Usage</h3>
  <div id="heap"></div>

  <h3>Non Heap Memory Usage</h3>
  <div id="nonheap"></div>
</body>
</html>

index.html

You find the source code for the entire project on GitHub.

For comparison, the equivalent WebFlux controller is much smaller because the framework can stream a reactive type directly.

@GetMapping(path = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<MemoryInfo>> memory() {
  return Flux.interval(Duration.ofSeconds(1))
      .map(sequence -> ServerSentEvent.builder(currentMemoryInfo())
          .id(Long.toString(sequence))
          .build());
}

That does not mean WebFlux is a mandatory upgrade. If your application is already on Spring MVC, SseEmitter remains a reasonable and well-supported choice.

More information

The following articles provide you with more information about Server-Sent Events.

Keeping track

At the end of this blog post, a shameless self-plug. Because Spring does not provide support for keeping track of SseEmitter instances, I wrote a library that does that for Spring Boot applications. The project is still maintained, but check the README for the version that matches your Spring generation. At the time of writing, the current release is 3.0.0.

<dependency>
  <groupId>ch.rasc</groupId>
  <artifactId>sse-eventbus</artifactId>
  <version>3.0.0</version>
</dependency>

To enable the library, you need to add the @EnableSseEventBus annotation to an arbitrary @Configuration class.

@SpringBootApplication
@EnableSseEventBus
public class Application {
    ...
}

The library creates a bean of type SseEventBus that an application can inject into any Spring-managed bean.

@Controller
public class SseController {
  private final SseEventBus eventBus;
  public SseController(SseEventBus eventBus) {
    this.eventBus = eventBus;
  }

  @GetMapping("/register/{id}")
  public SseEmitter register(@PathVariable("id") String id) {
    return this.eventBus.createSseEmitter(id, SseEvent.DEFAULT_EVENT);
  }
}

The library expects that each client sends a unique id. In modern browsers, an application can create such an id with crypto.randomUUID(). To start the SSE connection, the client calls the endpoint with the createSseEmitter method and sends the id and, optionally, the names of the events they are interested in.

const clientId = crypto.randomUUID();
const eventSource = new EventSource(`/register/${clientId}`);
eventSource.addEventListener('message', response => {
  // handle the response from the server
  // response.data contains the data line
}, false);

If you only have a small number of clients or a very simple topology, you may not need an additional library at all. A plain ConcurrentHashMap<String, SseEmitter> or a similar registry in application code is often enough. A library becomes more interesting when you need fan-out, subscriptions, lifecycle management, or reuse across multiple endpoints.

To publish messages, an application can either call the handleEvent method on the SseEventBus bean or publish a SseEvent object with Spring's event infrastructure.

@Service
public class DataEmitterService {
  private final SseEventBus eventBus;
  public DataEmitterService(SseEventBus eventBus) {
    this.eventBus = eventBus;
  }

  public void broadcastEvent() {
    this.eventBus.handleEvent(SseEvent.ofData("some useful data"));
  }
}
@Service
public class DataEmitterService {
  private final ApplicationEventPublisher eventPublisher;
  public DataEmitterService(ApplicationEventPublisher eventPublisher) {
    this.eventPublisher = eventPublisher;
  }

  public void broadcastEvent() {
    this.eventPublisher.publishEvent(SseEvent.ofData("some useful data"));
  }
}

Visit the GitHub project page for more information: https://github.com/ralscha/sse-eventbus
You find a demo application that uses the library in this repository: https://github.com/ralscha/sse-eventbus-demo