A real-time chat application built with Spring Boot and WebSockets
Features Β· Architecture Β· Quick Start Β· API Reference Β· Production
ChatWave is a full-stack, real-time messaging app that demonstrates how to build a production-quality WebSocket system with Spring Boot. It supports multiple chat rooms, live online user tracking, persistent message history, and real-time typing indicators β all running on a single server with zero external dependencies (H2 in-memory DB, Spring's built-in STOMP broker).
The project is intentionally structured to showcase three core backend concepts: WebSocket communication, asynchronous message handling, and real-time system design.
- Multiple chat rooms β Pre-seeded channels (
#general,#tech,#random,#announcements) with the ability to create new ones via REST - Online user tracking β Per-room and global user counts updated in real time via WebSocket connect/disconnect events
- Message persistence β All messages stored to a JPA database, retrieved asynchronously on room join (last 50 messages)
- Typing indicator β Debounced, pub/sub typing events broadcast per room, auto-cleared after 2.5 seconds of inactivity
- Message history β Delivered privately to each user on join via a user-specific STOMP queue (not broadcast to the whole room)
- Auto-reconnect β SockJS fallback transport with client-side reconnect on disconnect
- Async persistence β Messages are broadcast to clients immediately; DB writes happen on a dedicated thread pool in the background
- REST API β HTTP endpoints for rooms, users, and message history
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client β
β SockJS + STOMP.js + Vanilla JS β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β WebSocket / HTTP Fallback
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Boot Server β
β β
β ββββββββββββββββ ββββββββββββββββββββββββββββββββ β
β β REST Layer β β WebSocket Layer β β
β β /api/* β β /ws (SockJS endpoint) β β
β ββββββββββββββββ ββββββββββββββββ¬ββββββββββββββββ β
β β β
β ββββββββββββββββΌββββββββββββββββ β
β β ChatController β β
β β @MessageMapping handlers β β
β ββββββββββββββββ¬ββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββΌβββββββββββββββ β
β βΌ βΌ βΌ β
β ββββββββββββββββββ ββββββββββββββββββββ βββββββββββββ
β β MessageService β βOnlineUserService β βRoomServiceββ
β β (@Async write) β β (ConcurrentMap) β β (JPA) ββ
β βββββββββ¬βββββββββ ββββββββββββββββββββ βββββββββββββ
β β β
β βββββββββΌβββββββββββββββββββββββββββββββββββββββββ β
β β In-Memory STOMP Message Broker β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β H2 Database (JPA) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Destination | Direction | Purpose |
|---|---|---|
/app/chat.join |
Client β Server | Join a room |
/app/chat.send |
Client β Server | Send a message |
/app/chat.typing |
Client β Server | Typing status update |
/app/chat.leave |
Client β Server | Leave a room |
/topic/room/{id} |
Server β Client | Broadcast messages to a room |
/topic/room/{id}/typing |
Server β Client | Typing events for a room |
/topic/room/{id}/users |
Server β Client | Live user list for a room |
/topic/online-count |
Server β Client | Global online count |
/user/queue/history |
Server β Client | Private message history on join |
/user/queue/errors |
Server β Client | Private error delivery |
Joining a room:
Client Server
βββββ STOMP CONNECT ββββββββββββββββββΆ β
βββββ CONNECTED ββββββββββββββββββββββ β
βββββ SUBSCRIBE /topic/room/{id} ββββββΆ β
βββββ SUBSCRIBE /user/queue/history βββΆ β
βββββ /app/chat.join ββββββββββββββββββΆ β
β βββ register in OnlineUserService
β βββ persist JOIN msg (async)
βββββ /user/queue/history ββββββββββββ β β last 50 msgs, private to this user
βββββ /topic/room/{id} βββββββββββββββ β β JOIN system message (broadcast)
βββββ /topic/room/{id}/users βββββββββ β β updated user list (broadcast)
Sending a message:
Client Server
βββββ /app/chat.send ββββββββββββββββββΆ β
β βββ broadcast IMMEDIATELY
βββββ /topic/room/{id} βββββββββββββββ β β all subscribers receive it
β βββ persist to DB (async, non-blocking)
Typing indicator:
Client A Server Client B
βββ /app/chat.typing βββΆ β β
β {typing: true} βββ /topic/room/{id}/typing ββΆ β
β β β
β [2.5s no input] β β
βββ /app/chat.typing βββΆ β β
β {typing: false} βββ /topic/room/{id}/typing ββΆ β
src/main/java/com/chatapp/
βββ ChatApplication.java # Entry point (@EnableAsync)
β
βββ config/
β βββ WebSocketConfig.java # STOMP broker + SockJS endpoint
β βββ AsyncConfig.java # Thread pool for async DB writes
β βββ WebSocketEventListener.java # Connect/disconnect event hooks
β βββ DataInitializer.java # Seeds rooms + welcome messages on startup
β
βββ model/
β βββ Message.java # JPA entity (CHAT / JOIN / LEAVE / SYSTEM)
β βββ ChatRoom.java # JPA entity
β βββ UserSession.java # In-memory only, not persisted
β βββ ChatDTOs.java # All WebSocket payload DTOs (inbound + outbound)
β
βββ repository/
β βββ MessageRepository.java # findLastMessagesByRoomId w/ Pageable
β βββ ChatRoomRepository.java
β
βββ service/
β βββ MessageService.java # @Async saveMessageAsync + history fetch
β βββ RoomService.java # Room CRUD, default channel seeding
β βββ OnlineUserService.java # Thread-safe ConcurrentHashMap of live sessions
β
βββ controller/
βββ ChatController.java # All @MessageMapping WebSocket handlers
βββ RoomController.java # REST /api/* endpoints
βββ HomeController.java # Serves the SPA shell
src/main/resources/
βββ application.properties
βββ templates/index.html # Thymeleaf template (SPA shell)
βββ static/
βββ css/style.css # Dark terminal-inspired UI
βββ js/chat.js # STOMP client, typing debounce, DOM rendering
- Java 17+ β Download OpenJDK
- Maven 3.8+ β Download
# Clone the repo
git clone https://github.com/your-username/chatwave.git
cd chatwave
# Run
mvn spring-boot:runOpen http://localhost:8080, pick a username, and start chatting. Open a second browser tab to simulate a second user.
The H2 console is available at http://localhost:8080/h2-console while the app is running.
JDBC URL: jdbc:h2:mem:chatdb
Username: sa
Password: (leave blank)
mvn clean package -DskipTests
java -jar target/realtime-chat-1.0.0.jar| Method | Endpoint | Description |
|---|---|---|
GET |
/api/rooms |
List all rooms with live online counts |
POST |
/api/rooms |
Create a new room |
GET |
/api/rooms/{id}/messages |
Last 50 messages in a room |
GET |
/api/rooms/{id}/users |
Online users currently in a room |
GET |
/api/stats |
Global stats (total online, room count) |
Create a room:
curl -X POST http://localhost:8080/api/rooms \
-H "Content-Type: application/json" \
-d '{"name": "#design", "description": "UI/UX and design discussion"}'Get recent messages:
curl http://localhost:8080/api/rooms/general/messagesKey settings in src/main/resources/application.properties:
server.port=8080
# H2 in-memory (swap for PostgreSQL in production)
spring.datasource.url=jdbc:h2:mem:chatdb
spring.h2.console.enabled=true
# JPA
spring.jpa.hibernate.ddl-auto=create-dropReplace the H2 dependency in pom.xml:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Update application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/chatwave
spring.datasource.username=your_user
spring.datasource.password=your_password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialectThe app runs fine out of the box for demos and single-node deployments. For production, work through this checklist:
Infrastructure
- Replace H2 with PostgreSQL (see above)
- Replace
SimpleBrokerwith RabbitMQ or ActiveMQ for multi-node support β Spring's STOMP broker relay makes this a config-only change - Put the app behind nginx with WebSocket proxying and TLS (WSS)
Security
- Add Spring Security β protect HTTP endpoints and validate auth tokens on WebSocket connect
- Sanitize message content server-side (basic XSS escaping is already in
ChatController.sanitize()) - Rate-limit
/app/chat.sendper user
Reliability
- Add cursor-based message pagination (the
Pageablepattern inMessageRepositoryis already set up for this) - Store
UserSessionin Redis for recovery across server restarts - Add health and metrics endpoints via Spring Actuator
Multi-node STOMP relay (RabbitMQ) β swap this into WebSocketConfig.java:
// Replace enableSimpleBroker() with:
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest");Why async persistence?
WebSocket handlers run on a shared thread pool. Blocking on a DB write for every message degrades throughput under load. MessageService.saveMessageAsync() uses @Async with a dedicated ThreadPoolTaskExecutor β messages are broadcast to clients first, and the DB write follows in the background via CompletableFuture.
Why ConcurrentHashMap for online users?
WebSocket connect/disconnect events fire from multiple threads. ConcurrentHashMap gives lock-free reads and fine-grained locking on writes β a good fit for a structure that's read constantly but written infrequently.
Why user-private queues for history?
Message history is sent to /user/queue/history β a STOMP destination scoped to a single session. Publishing history to the room topic would re-broadcast it to all active users in the room, which would be wrong. Spring's convertAndSendToUser() handles the session scoping transparently.
Why SockJS? SockJS provides automatic fallback to HTTP long-polling when WebSockets are blocked (corporate proxies, firewalls). It's transparent to the STOMP layer and adds resilience for no cost.
MIT β use it however you like.