Building a Real-Time Chat Platform in Java from Scratch
Most chat tutorials start with a framework that hides the interesting parts. I wanted to see what it takes to build a real-time messaging system with plain Java — so I built BroadcastHub, a terminal-based chat platform powered by WebSockets.
This article walks through the problem, architecture, protocol design, persistence, private channels, Docker deployment, and what I learned along the way.
GitHub: https://github.com/bejoy-jbt/BroadcastHub
Docker: docker pull bejoy1514/broadcasthub:latest
The Problem
I set out to build something that could:
- Connect multiple terminal clients to one server in real time
- Support channels (like Slack rooms, but in the terminal)
- Allow private channels with invite codes
- Support direct messages between users
- Persist channels, message history, and bans across restarts
- Provide basic moderation tools for an admin
The constraint: no Spring Boot, no database — keep the stack small enough to understand every layer.
Architecture
BroadcastHub follows a layered design around a single WebSocket server:
Terminal Client
│
▼
WebSocket Server (BroadcastServer)
│
┌────────────────┐
│ ClientRegistry │ Maps WebSocket ↔ username
└────────────────┘
│
┌────────────────┐
│ ChannelManager │ Channels, membership, invite codes
└────────────────┘
│
┌────────────────┐
│ MessageHistory │ Per-channel message deque (max 100)
└────────────────┘
│
┌────────────────┐
│ AdminManager │ Mutes, bans, moderation state
└────────────────┘
│
┌────────────────┐
│ Persistence │ Jackson → data/*.json
└────────────────┘
Key classes:
| Component | Responsibility |
|---|---|
BroadcastServer |
WebSocket lifecycle, registration, routing |
ClientRegistry |
Username registration, online user lookup |
CommandProcessor |
Slash commands (/join, /create, /msg) |
ChannelManager |
Channel CRUD, membership, invite validation |
MessageHistory |
In-memory history with disk backup |
*Store classes |
JSON read/write via Jackson |
Entry point:
java -jar broadcasthub.jar server # start server
java -jar broadcasthub.jar client # start client
WebSocket Design
I deliberately chose a plain-text protocol instead of JSON messages. In a terminal client, you can read the wire format directly.
Registration
First message from client must be:
REGISTER|bejoy
Server responds:
REGISTER_SUCCESS|bejoy
or REGISTER_FAILED|reason.
Chat messages
After registration, plain text is broadcast to the user's current channel:
Hello everyone!
Server formats and relays:
[bejoy] Hello everyone!
Slash commands
Messages starting with / are intercepted by CommandProcessor:
/join gaming
/create java private
/msg alex Hey!
/help
Why this works
- Easy to test with
wscator any WebSocket client - No serialization overhead for a learning project
- Clear separation: registration → commands → chat
The server uses Java-WebSocket (org.java-websocket) for both server and client.
Persistence
BroadcastHub stores data under data/:
data/
├── channels.json # channel metadata + invite codes
├── history.json # per-channel message history
└── bans.json # banned usernames
Jackson handles serialization. On startup, stores load into memory; on change, they write back to disk.
Design choices:
- History capped at 100 messages per channel — prevents unbounded memory growth
- Bans persisted, mutes in-memory — bans are security-sensitive; mutes are session-level
- Channels saved on create/delete — invite codes survive restarts
Example channels.json:
{
"general": {
"name": "general",
"privateChannel": false,
"inviteCode": null
},
"java": {
"name": "java",
"privateChannel": true,
"inviteCode": "A7KD91"
}
}
Private Channels
Private channels add an invite code at creation:
/create java private
Server responds:
Private Channel Created
Code : A7KD91
To join:
/join java A7KD91
ChannelManager.canJoin() validates the code before switching the user's channel. On join, the server sends the last 10 messages so users have context.
Public channels skip invite validation — anyone can /join gaming.
Docker Deployment
A multi-stage Dockerfile builds with Maven and runs on JRE 17 Alpine:
# Build stage: mvn package
# Runtime stage: copy fat JAR, expose 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["server"]
Run server:
docker run -p 8080:8080 -v broadcasthub-data:/app/data bejoy1514/broadcasthub
Run client:
docker run -it --rm bejoy1514/broadcasthub client host.docker.internal 8080
Mounting /app/data keeps channels, history, and bans across container restarts.
Lessons Learned
1. Concurrent collections are non-negotiable
Multiple WebSocket threads read and write shared maps. ConcurrentHashMap and ConcurrentLinkedDeque prevented race conditions without manual locking everywhere.
2. Docker breaks stdin-based admin consoles
An admin console reading System.in fails in non-interactive containers. I added client-side admin via @@stats, @@ban user, etc. — pragmatic for Docker users.
3. Keep command routing separate
CommandProcessor owns user slash commands. BroadcastServer owns admin commands. Mixing them would have made both harder to extend.
4. File persistence is enough (for now)
For a portfolio project, JSON files beat setting up PostgreSQL. The tradeoff: no queryable history, no horizontal scaling — acceptable for v1.
5. Ship a one-command install
Publishing to Docker Hub (bejoy1514/broadcasthub) got more people trying it than "clone, mvn package, run" ever would.
What's Next
- Web UI client
- Secure WebSockets (WSS)
- Admin authentication
- Channel ownership and permissions
Try It
git clone https://github.com/bejoy-jbt/BroadcastHub.git
cd BroadcastHub
mvn clean package
java -jar target/broadcasthub-1.0-SNAPSHOT.jar server
Or pull the Docker image:
docker pull bejoy1514/broadcasthub:latest
Star the repo if you find it useful: https://github.com/bejoy-jbt/BroadcastHub
Questions or feedback? Leave a comment — I'd love to hear how you'd extend this.
Top comments (0)