Stream live data from Grails with server-sent events
Pushing Grails Data with Server-Sent Events is a practical fit for dashboards, notifications and lightweight feeds because it uses ordinary HTTP, works with Grails controllers and needs no extra browser package. The server holds one response open and sends new records as they happen, while the browser receives them through the native EventSource API. This makes the pattern approachable for Java developers who know Grails MVC but have not needed a separate WebSocket protocol.
Updates remain conventional HTTP responses, so they can be inspected with browser developer tools and routed through existing web controls. Grails services remain the source of truth while the browser applies each update to its current view. The essential design work lies in event identifiers, replay, slow-client handling, proxy configuration and access control.
Learn the event stream contract
Server-sent events provide a unidirectional channel over a normal HTTP response. Grails can publish order changes, build alerts, job results or sensor readings; the client listens but does not send application data through the same connection. That asymmetry suits read-only live views and makes SSE lighter to introduce than a bidirectional socket. It also fits existing Grails sessions because the stream can share the same origin and secure session cookie.
The wire format is deliberately plain. Each message is a block of UTF-8 text ended by a blank line. The id field identifies an event for replay, event names its type, data carries the payload and retry suggests how long the browser should wait before reconnecting. JSON fits comfortably in one data: line as long as literal newlines are escaped. A client listening for order can ignore unrelated event types while sharing the same connection. When the event field is omitted, EventSource delivers the message through its general message listener.
EventSource automatically reconnects after a dropped network or server restart and sends the last received identifier in the Last-Event-ID header. The server can use that value to replay missed records from a bounded history, preventing gaps while a user changes trains or moves between mobile and desktop connections. Since delivery can repeat around a reconnect, clients should also treat each event identifier as an idempotency key.
Create a Grails streaming endpoint
Place the stream behind a Grails controller and keep event creation in a service. The service should assign identifiers, serialise payloads, publish to subscribers and retain a short replay window. For a first local build, the controller can poll a BlockingQueue supplied by that service. The following action separates the HTTP and framing responsibilities from the business logic that creates an event.
import java.io.IOException
import java.util.concurrent.TimeUnit
class LiveEventController {
def liveEventService
def stream() {
response.contentType = 'text/event-stream'
response.characterEncoding = 'UTF-8'
response.setHeader('Cache-Control', 'no-cache, no-transform')
response.setHeader('X-Accel-Buffering', 'no')
response.writer.write('retry: 5000\n\n')
response.writer.flush()
def rawLastId = request.getHeader('Last-Event-ID')
def lastId = rawLastId?.isInteger() ? rawLastId.toLong() : 0L
def queue = liveEventService.subscribe(lastId)
try {
while (!Thread.currentThread().isInterrupted()) {
def event = queue.poll(15, TimeUnit.SECONDS)
if (event == null) {
response.writer.write(': keep-alive\n\n')
} else {
response.writer.write("id: ${event.id}\n")
response.writer.write("event: ${event.name}\n")
response.writer.write("data: ${event.data}\n\n")
}
response.writer.flush()
}
} catch (IOException ignored) {
// The browser closed the connection.
} finally {
liveEventService.unsubscribe(queue)
}
}
}
Here, liveEventService.subscribe(lastId) returns a queue of maps shaped like id, name and data. The service stores recent events in memory and offers them to a new subscriber whose identifier is older than the retained window. Its publish method adds each new map to every active queue. The history must be bounded because an unbounded cache turns a quiet reconnect into a memory leak. If the requested identifier predates the oldest retained event, send a fresh snapshot or resynchronisation event rather than silently omitting earlier updates.
This controller is suitable for a tutorial or a modest internal feed, but its loop occupies a servlet worker for the life of each connection. That cost becomes obvious when many dashboards reconnect together during a busy period. A production design should move long-lived connections to an asynchronous servlet or reactive service and use a shared broker for event distribution. Keeping the controller thin allows transport, replay policy and business events to evolve independently.
Connect the browser and protect the data
The browser side is compact. Native EventSource handles the connection and reconnection protocol, so the application only needs to register the event types it understands and update the relevant part of the page.
const feed = document.querySelector('#order-feed')
const status = document.querySelector('#connection')
const source = new EventSource('/live/stream')
source.addEventListener('open', () => {
status.textContent = 'Connected'
})
source.addEventListener('order', (message) => {
const order = JSON.parse(message.data)
const item = document.createElement('li')
item.textContent = `${order.reference} - ${order.status}`
feed.prepend(item)
})
source.addEventListener('error', () => {
status.textContent = 'Reconnecting...'
})
Using textContent for customer names and order references prevents a value in the payload from becoming executable HTML. Keep the status indicator honest: show Connected when the stream opens and Reconnecting... when it fails, while allowing EventSource to retry rather than replacing it with a new object after every error. Close an EventSource when a single-page component is destroyed; otherwise navigating between views can leave a dormant stream and its server-side subscriber running.
Treat the endpoint as a protected read operation. Check the session and authorisation before subscribing, and send only fields the current user may see. The native API does not permit an arbitrary Authorization header, so prefer a secure same-origin session cookie and avoid bearer tokens in query strings because they can leak through logs and browser history. A cross-origin EventSource needs carefully reviewed CORS and credential rules. Work through the site's basic security guidance and apply TLS, a strict Content Security Policy, output encoding and short event retention alongside the stream.
Scale, profile and observe the stream
Horizontal scaling changes the architecture. A BlockingQueue and in-memory history belong to one JVM, so another Grails instance cannot see those events. Publish domain events to Redis, Kafka or a managed pub/sub service, let each instance distribute them to connected browsers and keep replay data in a shared store. Give events stream-scoped identifiers that remain ordered when clients reconnect to a different instance.
Proxy configuration is equally important. Nginx, Apache and managed load balancers may buffer a response or close an apparently idle connection. Disable buffering for the stream route, set a read timeout longer than the heartbeat interval and avoid transforms that compress the event stream. The : keep-alive comment in the controller is valid SSE and helps intermediaries distinguish a healthy quiet stream from a failed one. It should not be counted as a business event.
Measure the complete path rather than only the framework. Track publish-to-browser latency, queue depth, dropped or slow subscribers, active connections, worker or event-loop usage, heap pressure, reconnect success and duplicate deliveries. Run a load test with realistic idle periods because a short benchmark can miss proxy and mobile-network timeouts. The Grails Example application profiling guide is a practical starting point for CPU and allocation hotspots; add stream-specific measurements to the same dashboard.
Deliver a useful Australian live feed
Australian use cases should account for geography and daily movement. A live order board might show AUD totals and GST-inclusive pricing, while an AFL, NRL or cricket scoreboard follows local audience demand. Display times with an explicit zone such as Australia/Sydney, Australia/Brisbane or Australia/Perth; Sydney and Melbourne change offset during daylight saving, while Brisbane does not. Rail commuters in Melbourne and Sydney can cross areas where coverage changes between tunnels, stations and suburbs, so resume and deduplication matter as much as first-delivery speed.
Keep personal information deliberate. If the Grails organisation is covered by the Privacy Act 1988, the Australian Privacy Principles require a clear purpose, limited collection, security and appropriate access and correction pathways. Decide retention before storing replay history, and avoid placing names, addresses or precise locations in event identifiers. If the feed is adapted for promotional communications, check the Spam Act 2003 and applicable consent requirements. Align operational controls with the ASD Essential Eight rather than treating a live endpoint as an exception.
A stream still needs visual restraint. A publication built around virtual visor stories shows how browser-delivered material can be framed as an experience rather than left as a raw developer log. For an operations dashboard, keep the live value prominent, label stale data clearly and provide a manual refresh path for users whose network never returns.
Build one narrow feed now: publish a single order-status event, subscribe with EventSource, then interrupt and restore the connection to verify heartbeat, replay and duplicate handling. Put it on a test Grails instance with the required authentication and proxy settings, show it to a small local user group and watch the stream through an ordinary mobile handoff. A feature that survives that test has a solid foundation for dependable daily use.