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.
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.
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.