Implementing Server-Sent Events with Spring Boot and Android

With HTTP/2 we can no longer use Web Sockets, so when we need to push data to the client from the server we need an alternative way. This is where Server-Sent Events come in. In this post, I will show how to implement it both in the backend, using Spring Boot, and on an Android client.

While HTTP/2 has been available for a long time, and most developers today are aware that it is significantly better than the previous versions of HTTP, fewer have an understanding of using Server-Sent Events for pushing data to the client. With HTTP/1.1 we could use Web Sockets to create an asynchronous message channel between the server and the client.

Unfortunately, Web Sockets are not available in HTTP/2. Instead, we have to use Server-Sent Events for pushing data from the server and use regular HTTP calls (GET, POST, DELETE, etc.) from the client. In this post, I will show how to implement it both in the backend, using Spring Boot, and in an Android client.

Why not Web Sockets?!?

Anyone familiar with Web Sockets would see that example in this post looks very similar to what we would do using that instead. However, since Web Sockets are not possible when doing HTTP/2, we need to use Server-Sent Events instead or fall back to using HTTP/1.1.

The problem with using HTTP/1.1 is that it is bad for performance, memory usage, and battery consumption. Since HTTP/1.1 will use one socket for each concurrent HTTP call, it means we will either create lots of TCP connections when doing concurrent calls, or we set an upper limit to the number of connections and risk having a much worse network performance.

On top of that, each TCP connection will take time to set up and use additional system resources, leading to excessive memory and battery usage which could easily be avoided simply by switching to HTTP/2.

While Server-Sent Events doesn't support sending messages from the client to the server, we don't really need to worry when using HTTP/2. A call with HTTP/2 uses header compression and other techniques in order to minimize the amount of data sent for each call. The details of this are outside the scope of this post, but it should be easy enough to find a good video or blog post explaining all of this for you. All you need to remember at this point is that a regular HTTP POST or GET with HTTP/2 replaces the Web Socket messages sent by the client in earlier HTTP versions.

Server-Sent Events 101

Server-Sent Events (or SSE) is a way for an HTTP call to keep the response stream open indefinitely and let the server send events asynchronously. This work both for HTTP/1.x and HTTP/2, but is much more efficient in the latter version as that will allow multiplexing of concurrent calls over the same socket.

The way it works is that the client makes a regular HTTP call, usually as a GET, but any HTTP method should work. The server response starts as a regular HTTP response with the content-type header set to text/event-stream. It is also recommended that the server sends Cache-Control: no-cache to prevent caching of event data.

Each event is now sent as plain text on the response stream. The stream is kept open for as long as the server or clients decides (or until a network error occurs).

At the very least, each event is a single line starting with data:  and ending with two new lines.

data: This is a Server-Sent Event!\n\n
A simple Server-Sent Event

Data in events can span multiple lines, with each line prefixed with data:. The following will result in a single event:

data: {\n
data:   "id":1234\n
data:   "name":"Erik"\n
data:   "description":"A description \n"
data:   spanning multiple\n
data:   lines"\n
data: }\n\n
An event with JSON data spanning multiple lines

Events can also have an optional ID, which can be used by the client when reconnecting as a header value (Last-Event-ID) to signal to the server what the last event it received was.

id: 12345\n
data: Hello, World!\n\n
SSE with an ID

When the client receives an event, it comes with an associated type. By default, the type will be message, but this value can be used to signal to the client what kind of data is sent in each event. You can think of the event field as light-weight content-type for the data in the event.

event: message\n
data: A simple text message\n\n

event: json-message\n
data: {"id":12345,"author":"Erik","message":"This is a JSON message"}\n\n

event: base64-message\n
data: VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIG1lc3NhZ2Uh\n\n
Three events with different event types

If the connection is closed by anyone but the client, the client will try to reconnect within roughly 3 seconds. The server can indicate to the client to change this if necessary using a retry field.

event: message\n
data: Hello, World!\n
retry: 10000\n\n
An SSE setting the timeout to retry the connection to 10 seconds.

Anything that doesn't match the above fields (data, event, id, or retry) is ignored by the client and won't trigger any events. The space between the colon of the field is common practice, it is not required and will be ignored.

An SSE API with Spring Boot

In this example, I'm using Spring Boot to create the API for our application. This framework has built-in support for Server-Sent Events and with the help of WebFlux we can use a reactive approach for the endpoint that responds with an event source.

@RestController
@RequestMapping("/chat")
class ChatController {
    private val messages = mutableListOf<Pair<Int, ChatMessage>>()
    private val sink = Sinks.many().multicast().directAllOrNothing<Pair<Int, ChatMessage>>()

    @PostMapping("/new")
    fun postMessage(@RequestBody chatMessage: ChatMessage): ResponseEntity<Unit> {
        val eventWithId = messages.size + 1 to chatMessage
        messages += eventWithId
        sink.emitNext(eventWithId, Sinks.EmitFailureHandler.FAIL_FAST)
        return ResponseEntity(HttpStatus.CREATED)
    }

    @GetMapping("/stream")
    fun chatEvents(@RequestHeader("Last-Event-ID") lastEventId: String?): Flux<ServerSentEvent<ChatMessage>> {
        val start = lastEventId?.toInt() ?: 0
        val oldEvents = messages
            .slice(start..messages.lastIndex)
            .map { buildSse(it.second, it.first.toString()) }
        return sink.asFlux()
            .map { buildSse(it.second, it.first.toString()) }
            .startWith(oldEvents)
    }

    private fun buildSse(chatMessage: ChatMessage, id: String): ServerSentEvent<ChatMessage> {
        return ServerSentEvent.builder<ChatMessage>().data(chatMessage).id(id).build()
    }
}
The Spring Boot controller for our API

In the example above we also send the id for each event. We also check for the Last-EventID header when creating the event source, and if present we only emit a subset of the older events.

Android client

You will need at two dependencies to do Server-Sent Events on Android. The first is the regular OkHttp client, and the second one is the okhttp-sse extension. The latter provides the APIs for connecting and listening to Server-Sent Events.

Add the following dependencies to your modules build.gradle file:

implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:okhttp-sse")
The OkHttp dependencies needed for Server-Sent Events on Android

The first thing we'll implement is the types for representing our events from the backend.

sealed class SseEvent

object Opened : SseEvent()

@JsonClass(generateAdapter = true)
data class ChatEvent(
    val author: String,
    val message: String
) : SseEvent()
Our data classes for our SSE endpoint

Next, we'll implement a simple repository class with two functions, one for sending a chat message and one for opening the event source for receiving events.

interface IdRepository {
    var lastEventId: String?
}

class SseDemoRepository(private val idRepository: IdRepository) {
    private val httpClient = OkHttpClient.Builder().build()
    private val chatEventAdapter: JsonAdapter<ChatEvent> =
        Moshi.Builder().build().adapter(ChatEvent::class.java)

    suspend fun sendChatMessage(message: ChatEvent) {
        // TODO
    }

    suspend fun chatEvents(): Flow<SseEvent> {
        // TODO
    }
}
The repository class

The IdRepository interface will be used to store and retrieve the latest ID that we receive, so we can pass that back to the API when the client restarts the event source. The underlying implementation for this could be a simple Data Store.

The sendChatMessage function will just do a regular HTTP POST call to the API. You could also use a Retrofit interface for this operation.

The chatEvents() function will return a Flow with SseEvent. We start by creating the OkHttp Request for this.

suspend fun chatEvents(): Flow<SseEvent> {
    val builder = Request.Builder()
        .get()
        .url(CHAT_API_URL)
        .cacheControl(CacheControl.FORCE_NETWORK)

    val lastEventId = idRepository.lastEventId
    if (lastEventId != null) {
        builder.header("Last-Event-ID", lastEventId)
    }

    val request = builder.build()
        
    // TODO: Make the call and return a Flow
}
The beginning of the chatEvents function where we create the initial Request

Using the Request we can now create an EventSource.

return callbackFlow {
    val listener = object : EventSourceListener() {
        override fun onClosed(eventSource: EventSource) {
            close()
        }

        override fun onEvent(
            eventSource: EventSource,
            id: String?,
            type: String?,
            data: String
        ) {
            val chatEvent = chatEventAdapter.fromJson(data) ?: return
            idRepository.lastEventId = id?.toInt()
            trySendBlocking(chatEvent)
        }

        override fun onFailure(
            eventSource: EventSource,
            t: Throwable?,
            response: Response?
        ) {
            close(t ?: IllegalStateException())
        }

        override fun onOpen(eventSource: EventSource, response: Response) {
            trySendBlocking(Opened)
        }
    }
    val eventSource =
        EventSources.createFactory(httpClient).newEventSource(request, listener)

    awaitClose { eventSource.cancel() }
}
The callbackFlow where we listen for events from the server

And that is all we need! The client will now only need to call chatEvents() which will connect to the event source and start emitting SseEvent objects on the returned flow. Once the client cancels the Flow, it will also get canceled and the event source closed.

Conclusions

SSE is a simple, yet effective way of pushing data from the backend to our clients. It is supported natively in JavaScript, the EventSource library provides full support on iOS, and with a small extension library to OkHttp you have full support on Android.

If you're struggling with adopting HTTP/2 for your backend because you're still using Web Sockets, consider switching to Server-Sent Events and reap all the benefits that come with HTTP/2.

Many thanks to my good friend Sebastiano and Parth for proof-reading this post!