← All projects

Real-time Chat with WebSocket and STOMP

Build multi-room chat with Spring WebSocket and STOMP, handshake authentication, presence, PostgreSQL history, heartbeats and reconnect, and horizontal scale-out through Redis pub/sub.

Real-timeIntermediate10-14 hours#websocket#real-time#redis#security#scalingSpring BootSpring WebSocketSTOMPSpring SecurityRedisPostgreSQLTestcontainers

Revise first: Multithreading & ConcurrencyJava NetworkingSpring Security & JWTRedis & Caching StrategiesScalability, Caching & Load Balancing

An HTTP request lives for milliseconds; a WebSocket lives for hours. That difference changes how you think: connections hold server memory, dead peers must be detected, clients reconnect and must catch up on what they missed, and a second server instance means a message received on instance A has to reach users connected to instance B. You will build chat rooms with history and presence, authenticate at the handshake, and scale out with Redis pub/sub while Spring's STOMP support handles framing, subscriptions and destinations.

Target behaviour

# 1. connect (browsers cannot set WebSocket headers, so the token is a query parameter)
CONNECT  ws://localhost:8080/ws?token=<jwt>        -> 101 Switching Protocols
         ws://localhost:8080/ws                    -> 401 (handshake rejected)
SUBSCRIBE /topic/rooms.general   and   /topic/rooms.general.presence
SEND      /app/rooms/general/send   {"text":"hello"}
MESSAGE   /topic/rooms.general
          {"id":42,"room":"general","user":"ana","text":"hello","at":"2026-01-05T10:00:00Z"}
SEND      /app/rooms/general/ping                  (every 20s; presence broadcast: ["ana","raj"])
# REST history and catch-up
GET /api/rooms/general/messages?limit=50           -> newest 50, oldest first
GET /api/rooms/general/messages?after=42           -> everything since id 42

Architecture

 browser ──ws://──▶ HandshakeInterceptor (JWT) ──▶ STOMP endpoint /ws
                              │ @MessageMapping /app/rooms/{id}/send
                              ▼
                       ChatService ──INSERT──▶ PostgreSQL (message history)
                              │
                              └─PUBLISH chat.room.{id}──▶ Redis
                                                            │ PSUBSCRIBE chat.room.*
   instance A  ◀────────────────────────────────────────────┤
   instance B  ◀────────────────────────────────────────────┘
        each instance: RedisListener -> SimpMessagingTemplate -> /topic/rooms.{id}
                       -> in-memory simple broker -> its own connected sockets
        presence: Redis sorted set per room, score = last heartbeat time

A message is persisted once by the instance that received it, then published to Redis. Every instance, including the sender's, delivers it to local subscribers from the Redis callback; also sending from the controller would deliver twice. The in-memory simple broker suffices because Redis does the cross-instance fan-out; a broker relay such as RabbitMQ is the alternative.

The core of it

Configuration: one endpoint, an application prefix for client sends, topics for broadcasts and server heartbeats.

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private final TaskScheduler heartbeat = new ThreadPoolTaskScheduler() {{ initialize(); }};
    private final AuthHandshake auth;
    WebSocketConfig(AuthHandshake auth) { this.auth = auth; }
    @Override public void registerStompEndpoints(StompEndpointRegistry r) {
        r.addEndpoint("/ws").addInterceptors(auth).setHandshakeHandler(auth.handler())
         .setAllowedOrigins("https://chat.example.com");
    }
    @Override public void configureMessageBroker(MessageBrokerRegistry r) {
        r.setApplicationDestinationPrefixes("/app");
        r.enableSimpleBroker("/topic").setHeartbeatValue(new long[] {10_000, 10_000})
         .setTaskScheduler(heartbeat);
    }
}

Authentication on handshake. Validate the token before the upgrade, return 401 if it is bad, and attach a Principal so @MessageMapping methods know who is sending. TokenVerifier wraps your JWT decoder, which also lets tests substitute a fake.

@FunctionalInterface
public interface TokenVerifier { String verify(String token) throws Exception; } // returns username

@Component
class AuthHandshake implements HandshakeInterceptor {
    private final TokenVerifier verifier;
    AuthHandshake(TokenVerifier verifier) { this.verifier = verifier; }

    @Override public boolean beforeHandshake(ServerHttpRequest req, ServerHttpResponse res,
            WebSocketHandler h, Map<String, Object> attrs) {
        String token = UriComponentsBuilder.fromUri(req.getURI()).build()
                .getQueryParams().getFirst("token");
        try {
            attrs.put("user", verifier.verify(token));
            return true;
        } catch (Exception e) {
            res.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }
    }
}

The handshake handler turns the verified user into the session Principal.

// method of AuthHandshake
DefaultHandshakeHandler handler() {
    return new DefaultHandshakeHandler() {
        @Override protected Principal determineUser(ServerHttpRequest r,
                WebSocketHandler h, Map<String, Object> attrs) {
            return () -> (String) attrs.get("user");
        }
    };
}

Schema and history: a BIGSERIAL id gives a total order and cheap keyset pagination, unlike offset paging. The table is message(id, room, username, body, sent_at) with CREATE INDEX ON message (room, id DESC).

public List<ChatMessage> after(String room, long afterId, int limit) {
    return jdbc.sql("SELECT id, room, username, body, sent_at FROM message "
                  + "WHERE room = :r AND id > :a ORDER BY id LIMIT :n")
        .param("r", room).param("a", afterId).param("n", Math.min(limit, 200))
        .query(MAPPER).list();
}

Without after, run it with ORDER BY id DESC LIMIT :n and reverse. Sending: persist, then publish. The payload contains the database id, so clients can deduplicate and use it as the after cursor on reconnect.

@Controller
class ChatController {
    private final ChatService chat;
    ChatController(ChatService chat) { this.chat = chat; }

    @MessageMapping("/rooms/{room}/send")
    void send(@DestinationVariable String room, @Payload @Valid SendRequest req, Principal user) {
        chat.post(room, user.getName(), req.text());
    }
    @MessageMapping("/rooms/{room}/ping")
    void ping(@DestinationVariable String room, Principal user) { chat.presence().touch(room, user.getName()); }
}

public record SendRequest(@NotBlank @Size(max = 2000) String text) {}
@Transactional
public ChatMessage post(String room, String user, String text) {
    ChatMessage m = repo.insert(room, user, text);          // RETURNING id, sent_at
    redis.convertAndSend("chat.room." + room, mapper.writeValueAsString(m));  // fan-out to all instances
    return m;
}
@Bean
RedisMessageListenerContainer redisListeners(RedisConnectionFactory cf, SimpMessagingTemplate ws) {
    var container = new RedisMessageListenerContainer();
    container.setConnectionFactory(cf);
    container.addMessageListener((msg, pattern) -> {
        String channel = new String(msg.getChannel(), StandardCharsets.UTF_8);
        String room = channel.substring("chat.room.".length());
        ws.convertAndSend("/topic/rooms." + room, new String(msg.getBody(), StandardCharsets.UTF_8));
    }, new PatternTopic("chat.room.*"));
    return container;
}
public void touch(String room, String user) {
    String key = "presence:" + room;
    long now = System.currentTimeMillis();
    redis.opsForZSet().add(key, user, now);
    redis.opsForZSet().removeRangeByScore(key, 0, now - 60_000);   // evict stale
    Set<String> online = redis.opsForZSet().range(key, 0, -1);
    redis.convertAndSend("chat.room." + room + ".presence", json(online));
}

The listener is one pattern subscription. Presence is a sorted set per room scored by last heartbeat, so a dead client simply stops refreshing. Presence updates reuse the same listener: channel chat.room.general.presence lands on /topic/rooms.general.presence with no extra code.

Authorising subscriptions matters as much as the handshake: authentication says who you are, not which rooms you may read. Add a ChannelInterceptor via configureClientInboundChannel that checks room membership on SUBSCRIBE frames and throws MessagingException when denied.

Client reconnect belongs to the client but defines the protocol. Use exponential backoff with jitter, reconnect with a fresh token, resubscribe, then fetch GET /messages?after=<lastId>, and deduplicate by id because messages can arrive both ways.

Testing

Run real PostgreSQL and Redis and a real STOMP client; the test swaps TokenVerifier so the token is the username.

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class ChatIT {
    @Container @ServiceConnection
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
    @Container @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:7").withExposedPorts(6379);
    @TestConfiguration static class Cfg { @Bean @Primary TokenVerifier fake() { return t -> t; } }
    @LocalServerPort int port;
    StompSession connect(String user, BlockingQueue<String> inbox) throws Exception {
        var client = new WebSocketStompClient(new StandardWebSocketClient());
        client.setMessageConverter(new StringMessageConverter());
        StompSession s = client.connectAsync("ws://localhost:" + port + "/ws?token=" + user,
                new StompSessionHandlerAdapter() {}).get(5, TimeUnit.SECONDS);
        s.subscribe("/topic/rooms.general", new StompFrameHandler() {
            public Type getPayloadType(StompHeaders h) { return String.class; }
            public void handleFrame(StompHeaders h, Object p) { inbox.add((String) p); }
        });
        return s;
    }
}
// inside ChatIT
    @Test
    void messageReachesOtherSession() throws Exception {
        var inboxB = new LinkedBlockingQueue<String>();
        connect("raj", inboxB);
        StompSession ana = connect("ana", new LinkedBlockingQueue<>());
        ana.send("/app/rooms/general/send", "{\"text\":\"hello\"}".getBytes());
        assertThat(inboxB.poll(5, TimeUnit.SECONDS)).contains("\"user\":\"ana\"", "hello");
    }

Subscriptions are asynchronous, so wait for them (Awaitility) before sending or the test is flaky. For scale-out, run two contexts on different ports sharing one Redis and assert cross-delivery.

Bugs you will hit

  • Duplicate messages for the sender. The controller broadcasts locally and the Redis listener broadcasts again. Choose one path: persist, publish to Redis, and deliver only from the listener.
  • Handshake 403 or silent failure behind a proxy. nginx drops Upgrade and Connection headers by default, and setAllowedOrigins rejects mismatched Origin. Forward the upgrade headers, raise proxy_read_timeout above your heartbeat interval, and list real origins instead of *.
  • Tokens in the URL end up in logs. Query strings are written to access logs and proxies. Use a short-lived single-use ticket obtained from an authenticated REST call, redact the parameter in logging, and keep tokens short-lived.
  • Phantom online users and message gaps after reconnect. Relying on disconnect events leaves ghosts when a connection dies without a close frame, and Redis pub/sub is fire-and-forget so a briefly disconnected instance misses messages. Expire presence by heartbeat score and have clients backfill from history by id after every reconnect.

Interview angle

  • WebSocket versus SSE versus long polling? WebSocket is full duplex over one upgraded TCP connection; SSE is server-to-client only over plain HTTP with automatic reconnect and works through more proxies; long polling is the universal fallback with the highest overhead. Chat with typing and presence suits WebSocket.
  • Why STOMP on top of WebSocket? Raw WebSocket gives you frames and nothing else. STOMP adds destinations, subscriptions, headers, heartbeats and acknowledgements, so Spring can route to @MessageMapping methods and a broker without you inventing a protocol.
  • How do you scale beyond one instance, and what are the guarantees? Connections are sticky to an instance, so you need a shared fan-out layer: Redis pub/sub, or a STOMP broker relay. Redis pub/sub is at-most-once, so durability comes from PostgreSQL and a catch-up query by id, not from the channel.

Milestones

0 of 7 milestones

Done when

Stretch goals