VirtualRare
Writing

Server sent events in Grails 6, without RxJava.

If you want a live updating page in Grails, the search results send you to one place: the official guide on sending server sent events. It works, but it targets Grails 3, and it pulls in RxJava to do it. That is a reactive streams library and a new mental model, added to your build, so that the server can push a line of text at a browser.

You do not need it. The Servlet container already knows how to hold a response open. Grails runs on Spring Boot, which runs on Tomcat, which has had async support since Servlet 3.0. What follows is the whole thing in about forty lines, running in production across several applications.

The trap worth knowing first

The obvious move, if you know Spring, is to return a SseEmitter from a controller action. In a Spring MVC @RestController that is exactly right.

This fails silently

A Grails action that returns a Spring SseEmitter never reaches Spring's async return value handler. Grails has its own logic for interpreting what an action returns, and an SseEmitter is not part of that vocabulary, so it is treated as an ordinary object. The browser gets an empty response and a closed connection. No exception, no warning in the log, nothing to search for.

That failure mode is why so many answers reach for a plugin. The fix is not a plugin. The fix is to stop returning anything at all and to talk to the Servlet API directly.

Opening the stream

A Grails action gets request and response for free. Call startAsync() and the container stops trying to finish the response when the action returns.

import javax.servlet.AsyncContext
import javax.servlet.AsyncEvent
import javax.servlet.AsyncListener

// Every open stream. Static, because it has to outlive the request.
private static final List<AsyncContext> sseContexts =
        Collections.synchronizedList([])

def sse() {
    AsyncContext ac = request.startAsync()
    ac.timeout = 0L                     // never time the stream out

    response.contentType = 'text/event-stream'
    response.characterEncoding = 'UTF-8'
    response.setHeader('Cache-Control', 'no-cache')
    response.setHeader('Connection', 'keep-alive')
    response.setHeader('X-Accel-Buffering', 'no')

    try {
        def w = response.writer
        w << "event: heartbeat\ndata: connected\n\n"
        w.flush()
    } catch (Exception ignored) {
        try { ac.complete() } catch (Exception e) {}
        return
    }

    sseContexts.add(ac)
    // No return value. The response stays open.
}

Four of those lines earn their place and are worth calling out.

ac.timeout = 0L

The default async timeout will close a quiet stream out from under you. Zero means never. If you would rather have the container reap idle streams, set a real number and handle onTimeout, but do it deliberately.

X-Accel-Buffering: no

This is the one that will cost you an afternoon. Put a reverse proxy in front of the app and it will happily buffer your event stream, holding each message until it has enough bytes to be worth forwarding. Your events arrive in bursts, or not at all, and everything looks fine locally where there is no proxy. Nginx and Caddy both honour this header.

The heartbeat write

Writing and flushing immediately does two things: it confirms the client is really there before you add it to the list, and it forces the headers out so the browser fires onopen rather than sitting in a pending state.

No return value

Returning a model here would be Grails trying to render a view into a response you have taken ownership of. Return nothing.

Cleaning up after clients that leave

Browsers close tabs, laptops sleep, mobile connections drop. Without cleanup, that list grows until the process runs out of memory. Register a listener when the stream opens.

ac.addListener([
    onComplete  : { AsyncEvent e -> sseContexts.remove(ac) },
    onTimeout   : { AsyncEvent e ->
        sseContexts.remove(ac)
        try { ac.complete() } catch (Exception ex) {}
    },
    onError     : { AsyncEvent e -> sseContexts.remove(ac) },
    onStartAsync: { AsyncEvent e -> },
] as AsyncListener)

Groovy coerces a map of closures into the interface, so you get all four methods without writing a class. onStartAsync has to be present even though it does nothing, because the interface requires it.

Broadcasting

Sending an event means writing the wire format to every open context. The format is plain text and worth knowing rather than abstracting: an event: line, a data: line, and a blank line to terminate the message.

static void broadcastEvent(String eventName, String data) {
    synchronized (sseContexts) {
        List<AsyncContext> dead = []

        sseContexts.each { AsyncContext ac ->
            try {
                def w = ac.response.writer
                w << "event: ${eventName}\ndata: ${data}\n\n"
                w.flush()
            } catch (Exception e) {
                dead << ac          // client vanished mid write
            }
        }

        dead.each { AsyncContext ac ->
            try { ac.complete() } catch (Exception e) {}
            sseContexts.remove(ac)
        }
    }
}

A write to a client that has already gone throws. Collect those and remove them after iterating, never during, or you are mutating a list while walking it. The listener above catches most departures, but not a connection that dies without the container noticing, which is why the write path reaps as well.

It is static so anything in the application can call it without holding a reference to the controller. A service that has just saved a record can announce it directly.

The client half

With htmx, the whole frontend is two attributes. The SSE extension connects, and any element can swap itself when a named event arrives.

<div hx-ext="sse" sse-connect="/universal/sse">
  <div sse-swap="Message-create"
       hx-get="/messages/list"
       hx-trigger="sse:Message-create">
  </div>
</div>

Without htmx it is the standard browser API, which has reconnection built in already.

const es = new EventSource('/universal/sse')
es.addEventListener('Message-create', e => {
    console.log('new message', e.data)
})

What this costs you

Async contexts do not hold a thread while idle, so open streams are cheap in a way that a thread per client is not. Two real limits are worth naming.

  • It is per instance. That list lives in one JVM. Run two containers behind a load balancer and a broadcast from one reaches only the clients connected to it. At that point you want the instances talking over something shared, and that is a different article.
  • It is one directional. Server to client only. That is usually the whole requirement, and it costs a fraction of what a websocket does.

For a single instance pushing updates to connected users, which is most internal applications and plenty of public ones, this is the entire implementation. No RxJava, no plugin, no websocket, no polling.

Why it is not written down

The official guide predates Grails 6 and predates htmx being an obvious way to build. It was written when reactive was the answer to everything, and it has not needed revisiting because the people who hit this problem tend to solve it once, in a codebase nobody else reads, and move on.

The SseEmitter silence is the part that costs people the most time, because it looks like your code is wrong when the framework is simply not looking at what you returned.

Working on something like this

Grails, htmx, Postgres

This is the stack I build in every day. If you have a Grails application that needs to do something it currently cannot, I am happy to talk about it.

Get in touch