Real-time notifications with Spring Boot and Server-Sent Events
When a customer in Sydney waits for a funds transfer to clear, the application earns its keep by pushing fresh information to the browser without delay. Polling drains data allowances watched carefully by Australian users on capped NBN plans. A persistent stream of server-pushed updates keeps dashboards current and notification pop-ups relevant without the client asking again.
Server-Sent Events offer a practical middle ground between short-lived HTTP requests and the heavier WebSocket protocol. They ride on plain HTTP, traverse proxies without fuss, and suit a small in-house team. For one-way flows such as order status, ticket updates, or alerts from a Java Spring Boot backend, the most straightforward approach is often the best. This article walks through designing and implementing such a system, with notes from Australian production deployments.
Why choose SSE for one-way push
Server-Sent Events deliver text-based messages from server to browser over a single long-lived HTTP connection. The protocol is older than many expect, yet every browser since IE11 supports it natively. The connection auto-reconnects when the network drops, which matters for commuters on Sydney trains through tunnels, or rural customers on regional networks where line interruptions are common.
Compared with a full WebSocket channel, an SSE stream consumes fewer server threads and avoids the handshake that confuses older load balancers. The trade-off is that the channel flows one way only, which matches notification work where the client mainly listens and occasionally sends acknowledgements through a regular POST.
Setting up the project skeleton
Spring Boot 3 with spring-boot-starter-web is the right starting point. Jackson is pulled in transitively for serialising JSON payloads, and Spring MVC ships with everything required to return an SseEmitter from a controller. For applications behind Nginx or an AWS load balancer in ap-southeast-2, no extra configuration is needed beyond disabling response buffering.
In application.yml, set the server port and adjust logging so the SSE controller shows minimal noise during normal operation. Spring's actuator endpoints can monitor open emitters, which helps when capacity planning for events like ASX market open or end-of-financial-year lodgement storms.
Implementing the SSE endpoint
The core building block is the org.springframework.web.servlet.mvc.method.annotation.SseEmitter class. A controller method returns an emitter, and Spring holds the response open while it sends events. A typical implementation sets a timeout, registers the emitter with a connection manager, and wires callbacks for completion and error scenarios.
@GetMapping(value = "/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
SseEmitter emitter = new SseEmitter(TimeUnit.MINUTES.toMillis(30));
registry.register(emitter, lastEventId);
emitter.onCompletion(() -> registry.remove(emitter));
emitter.onTimeout(() -> registry.remove(emitter));
return emitter;
}
Each event can carry an id, a name, and a JSON payload. Naming events helps front-end code route messages, for example order.shipped, payment.received, or ticket.updated.
Managing connections and broadcasting
Holding every active emitter inside a ConcurrentHashMap gives thread-safe access from any service that pushes updates. A small broadcaster iterates the registry, attempts to send each event, and removes any emitter that throws an IOException. This pattern keeps it isolated from business logic and easy to unit-test.
A scheduled task should send a comment heartbeat every twenty seconds. Without it, intermediate proxies sometimes drop quiet HTTP connections after their idle timeout. From Australian hosting environments in Sydney or Singapore regions, that idle window often sits around sixty seconds, so a fifteen-second heartbeat is a safe upper bound.
Connecting business events to the stream
The broadcaster can be injected wherever domain events fire, or subscribed through Spring's ApplicationEventPublisher for a more decoupled approach. When a payment gateway confirms a settlement, or a CommBank-style webhook posts to the backend, the service publishes an internal event and the broadcaster fans it out to every connected client. Australian fintech teams have used this pattern to mirror BNPL settlement events into merchant dashboards.
Authentication is enforced through Spring Security using a JWT or session cookie. Because SSE connections live for many minutes, the emitter registry should keep a reference to the principal so disconnects and reconnects can replay missed events between the Last-Event-ID marker and the present.
Replays, scaling and observability
When a client reconnects after a network drop, the Last-Event-ID header lets the server replay missed events. Persisting recent events to Redis with a TTL of a few minutes is enough for most scenarios. For a single-instance deployment, an ArrayBlockingQueue works equally well and removes the network hop.
Once the application runs across several pods behind a load balancer, SSE streams still work, but a message published to one pod must reach clients connected to the others. Redis Pub/Sub or a Kafka stream solves the routing problem with minimal code. Monitoring should track active emitter counts, the rate of failed sends, and the average latency between publish and delivery, since these metrics drive the alerts that protect the user experience.
| Feature | Server-Sent Events | WebSockets | Short polling |
|---|---|---|---|
| Direction | Server to client | Bidirectional | Client to server |
| Protocol | Plain HTTP | HTTP upgrade | HTTP |
| Browser support | Native, since IE11 | Native, modern browsers | Universal |
| Proxy friendly | Yes | Sometimes | Yes |
| Server overhead per client | One async task | One async task | None persistent |
| Reconnection handling | Automatic | Manual | N/A |
Recommendations for a robust deployment
- Keep heartbeat comments shorter than the shortest upstream idle timeout to survive proxies in Australian data centres.
- Wrap emitter writes in try-catch blocks and evict failed connections immediately to avoid memory leaks.
- Store the user principal alongside the emitter so authorisation stays consistent across the connection.
- Use Redis or Kafka once the app runs across more than one pod, since in-memory pod replicas cannot reach each other's subscribers.
- Persist recent events with a short TTL to support Last-Event-ID replay after transient drops.
- Expose actuator metrics for active emitter counts and failed sends to feed dashboards and alerting rules.
- Test behind Nginx or an AWS ALB with response buffering disabled, because buffered responses defeat streaming.