Using STOMP over WebSockets in a Grails application

Real-time dashboards, order alerts and collaborative forms need a faster path from server events to the browser than repeated polling. STOMP over WebSockets provides that path: a persistent bidirectional connection carries small text frames, while a Grails controller decides which application events may become browser notifications. The result is lower perceived latency and a clearer separation between ordinary HTTP requests and live updates.

STOMP, the Simple Text Oriented Messaging Protocol, adds a familiar envelope to a WebSocket stream. Commands, destination headers and JSON bodies travel in ordered frames, and a broker can route messages to topics or individual users. In Grails, Spring’s messaging abstractions sit comfortably beside controllers, services and GORM, so the same application can accept conventional requests, publish domain events and maintain live subscriptions without creating a custom socket protocol.

Australian deployments add practical constraints. A team can be distributed across Sydney, Melbourne, Brisbane and Perth, where operational teams in Perth often begin earlier than those in the eastern states. Customer support and release windows may also follow local business hours. Services sold into the Australian market must fit local privacy expectations, including the Australian Privacy Principles, OAIC guidance and the Notifiable Data Breaches scheme, so live payloads should reveal only what each subscriber is authorised to see.

The implementation below uses a native browser WebSocket, a Spring STOMP endpoint and an in-memory broker for development. It then moves towards authenticated destinations, automated tests and a broker relay for multi-instance production hosting. The examples assume a conventional Grails application using Grails Build, Spring Security and a package under au.com.example.

Why protocol layering fits Grails

A browser first requests an HTTP upgrade at /stomp. Once the server accepts it, the same TCP connection remains open for WebSocket frames. STOMP then structures that stream with CONNECT, SUBSCRIBE, SEND, MESSAGE and DISCONNECT commands. Headers identify the destination, while the body carries JSON or another negotiated format. Each STOMP frame ends with a null character, although a client library should handle that detail for you.

This protocol layering matters because the WebSocket handles transport, STOMP handles message semantics, and Grails code handles authorisation and business rules. Broker destinations can remain independent of controller routes, which makes it easier to publish an event from a service, deliver it to a topic and let several browser sessions consume it without coupling those sessions to the code that created the event.

For a visual companion when explaining the browser side, the linked browser WebSocket walkthrough can sit beside a capture from your own application. The browser’s Network panel remains the quickest place to confirm the HTTP upgrade, frame direction, payload size and close code.

Choose the broker deliberately. Spring’s simple in-memory broker suits one Grails instance, automated tests and prototypes, but its subscriptions are not shared between nodes. For horizontal scale, connect the application through a broker relay to RabbitMQ or ActiveMQ with its STOMP adapter. A shared broker lets a message published on one node reach clients connected to another, although every node still needs matching destination rules, serialisers and authorisation policy.

Build the STOMP message pipeline

Add the WebSocket and messaging starters, allowing Grails dependency management to supply compatible Spring versions:

dependencies {
    implementation "org.springframework.boot:spring-boot-starter-websocket"
    implementation "org.springframework.boot:spring-boot-starter-messaging"
}

Create a configuration class in a package scanned by the application and register the broker prefixes and endpoint:

package au.com.example.realtime

import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker('/topic', '/queue')
        registry.setApplicationDestinationPrefixes('/app')
        registry.setUserDestinationPrefix('/user')
    }

    @Override
    void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint('/stomp')
    }
}

A client publishes to /app/notifications, while @MessageMapping('/notifications') receives the command. Resolve the authenticated principal, apply an application policy and publish a deliberately shaped response. Do not expose a mutable GORM domain object directly because its properties may contain data the browser should not receive.

class NotificationController {

    NotificationService notificationService

    @MessageMapping('/notifications')
    void receive(NotificationCommand command, Principal principal) {
        notificationService.publish(command, principal)
    }
}

class NotificationService {

    SimpMessagingTemplate messagingTemplate

    void publish(NotificationCommand command, Principal principal) {
        if (principal == null) {
            throw new AccessDeniedException('Authentication required')
        }
        if (!command.body?.trim() || command.body.length() > 2000) {
            throw new IllegalArgumentException('Invalid notification')
        }

        def payload = [
            id     : UUID.randomUUID().toString(),
            body   : command.body.trim(),
            sentAt : Instant.now().toString()
        ]

        messagingTemplate.convertAndSendToUser(
            principal.name, '/queue/notifications', payload)
    }
}

On the browser, Stomp.js can negotiate heartbeats, reconnect with backoff and subscribe after authentication. Install @stomp/stompjs, then adapt the following client to your interface. A same-origin secure session cookie is usually cleaner than placing a bearer token in the query string because URLs frequently appear in browser history, proxies and logs.

import { Client } from '@stomp/stompjs'

const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'

const client = new Client({
    brokerURL: `${scheme}//${location.host}/stomp`,
    reconnectDelay: 5000,
    heartbeatIncoming: 10000,
    heartbeatOutgoing: 10000,
    onConnect: () => {
        client.subscribe('/user/queue/notifications', ({ body }) => {
            const notification = JSON.parse(body)
            renderNotification(notification)
        })
    }
})

client.activate()

Here, renderNotification is an application function that updates the relevant component or store. If the security configuration requires a CSRF value during CONNECT, include it through the client library’s connection headers rather than disabling the protection.

Secure the connection and destination

Treat the socket as a public entry point. Perform the HTTP upgrade only over TLS in production, reject unexpected Origin values, and authenticate the STOMP CONNECT frame or its accompanying session. Native browser WebSockets cannot set an arbitrary Authorization header, so a secure, HttpOnly, SameSite cookie or a short-lived token exchanged during CONNECT is usually more practical. Configure CSRF protection for the STOMP connection flow; an Origin check alone is not a substitute for authorisation.

Authorise every destination, not merely every connection. A valid session might still request a topic it cannot read, so use Spring Security’s message-destination rules and service-level checks against the authenticated principal. Treat STOMP headers as untrusted input. When a notification also updates a GORM record, coordinate concurrent changes; the site’s pessimistic locking guide shows how database locks can prevent one update from overwriting another transaction’s work.

Keep frames small and predictable. Use a versioned DTO, whitelist accepted fields, cap the body size, apply per-user and per-IP rate limits, and never log access tokens or sensitive payloads. Give every event an identifier so a reconnecting client can discard duplicates, and define whether missed messages must be replayed from the database or can be skipped. These controls reduce both data leakage and the load created by reconnect storms.

A practical verification and launch checklist

Exercise the endpoint as a client, not just with curl. Spring’s WebSocketStompClient can connect through a test WebSocket server, subscribe to a user destination and assert on the returned payload. Integration tests should cover an authenticated CONNECT, an unauthorised subscription, a valid SEND and a reconnect carrying a duplicate event identifier. When latency grows under realistic traffic, measure first; the Grails profiling techniques article explains how profiling can locate expensive work in a Grails request path.

Before deployment, verify the protocol and connection behaviour:

Then check the application and operational boundaries:

Run load tests with the JSON payload sizes and subscription patterns expected in production. Include a deliberate broker outage to confirm that the client backs off rather than creating a tight reconnect loop. In browser developer tools, distinguish a normal reconnect from an authentication failure, an authorisation rejection and a server error. That separation shortens incident diagnosis and gives support teams in Sydney and Melbourne comparable evidence across an incident timeline.

Deploy the service and connect with users

For Australian hosting, choose a region close to the primary users and data boundary. AWS ap-southeast-2 in Sydney and ap-southeast-4 in Melbourne are common options for Australian customers, while a local deployment or an appropriately governed overseas region may suit a different workload. Confirm idle-timeout settings on the load balancer, keep WebSocket upgrades enabled, and set a generous but finite duration so abandoned sessions do not accumulate indefinitely.

When more than one Grails node serves the application, replace the simple broker with a broker relay and a shared STOMP-capable message broker. Keep WebSocket affinity only if the chosen transport requires it; do not rely on sticky sessions as a substitute for shared subscriptions. Test failover during node maintenance, account for Perth’s offset from Sydney when scheduling releases, and publish connection metrics in UTC while keeping customer-facing schedules in Australian local time.

Start with one authenticated user destination, one validated command and one event identifier. Run the Grails application behind TLS, connect a test browser client from an Australian network, and confirm that the message arrives through /stomp without polling. Then create the feature branch, add the endpoint and security rules, and move the first live notification into a monitored staging environment before the release reaches customers.