There's a failure mode that almost every realtime app shares: the websocket drops, and the application stops being an application. Messages don't send. The input box lies to you. Everything hangs on a reconnect that may or may not be coming.
Fluid Chat is built so that when the socket dies, the worst thing that happens is you stop seeing other people type.
The rule: writes never go over the socket
This is the whole design in one sentence. Every write is an HTTP request. Send a message, add a reaction, edit, pin, upload — all of it is a normal request to /api that either succeeds or fails, with a status code and a response body.
The websocket carries exactly one direction of traffic: events, outbound, to clients. Somebody else sent a message; here it is. Somebody started typing; here's that. Presence changed; here's that too.
It also makes the API honest. Because writes are HTTP, the web client has no privileged path — it calls the same endpoints you can, which is why there's a generated OpenAPI 3.1 document and a GET /api/meta/routes discovery endpoint. A private websocket protocol for writes is how products end up with an API that mysteriously can't do what the app does.
The path a message takes
- 1The client renders it optimistically. Your message appears immediately, with a client-generated idempotency key attached.
- 2An HTTP POST hits the app. Authorization, validation, and the write to Postgres happen in one transaction — including the search index, so the message is findable the instant it exists.
- 3The app notifies the realtime server. A single process posts directly over HTTP; multiple processes publish to Redis instead.
- 4The realtime server fans out to the clients subscribed to that conversation's room, and only those clients.
- 5Recipients render it. The sender reconciles their optimistic copy with the server's version — same idempotency key, so a retry can never produce a duplicate.
Step 5 is why flaky networks are boring here. If the POST times out and the client retries, the key makes the second attempt a no-op that returns the original message. Double-sends are a class of bug that requires no vigilance.
Rooms and authorization
A naive realtime layer broadcasts to everyone connected and filters client-side, which is a data leak wearing a performance costume. Fluid does room-level authorization at subscription time: a socket joins the room for a conversation only after the server confirms that user can read it.
- Private channel events reach only its members.
- DM events reach only the participants.
- Guests are confined to the channels they've been added to, in the realtime layer as well as the API — the same permission helpers back both.
- Membership changes update subscriptions, so removing someone from a channel actually removes them.
The important detail is that authorization isn't reimplemented for sockets. Both paths go through the same workspace-scoped permission helpers, because two implementations of the same rule is a promise that they'll disagree eventually.
Presence and typing
Presence — active, away, do-not-disturb — runs on heartbeats. Typing indicators are relayed per composer and deliberately never persisted, because a typing indicator has a useful lifetime of about four seconds and no historical value whatsoever.
Both are pure socket concerns, and both are the first things you lose in a realtime outage. That's the right thing to lose.
Scaling out, and where Redis comes in
With one app process and one realtime process, the app posts events directly to the realtime server over HTTP. No broker, no queue, nothing to configure. Most self-hosted deployments never leave this shape and never think about any of it.
Run two app processes and you have a problem: a message written through process A needs to reach a client connected to a realtime server that process A isn't talking to. Enter Redis:
client --POST--> app ---> postgres | +--HTTP--> realtime ---> subscribed clientsclient --POST--> app-2 ---> postgres | +--publish--> redis | +--------------+--------------+ v v realtime-1 realtime-2 | | subscribed clients subscribed clientsThat's the entire reason Redis exists in this stack, and it's why it's optional. A dependency that's genuinely needed at one scale and genuinely unnecessary at another should be configurable, not mandatory — making everyone run Redis so that the 5% who scale out don't have to change anything is a tax on the 95%.
What happens when things break
| Failure | What you lose | What still works |
|---|---|---|
| Realtime server down | Live updates, typing, presence | Sending, reading, search, uploads, everything else |
| Redis down (multi-process) | Cross-process event delivery | All writes; same-process clients still update live |
| Postgres down | Everything | Nothing. It's the database. |
| Worker down | Scheduled sends, reminders, retention, exports | All interactive use |
| Object storage down | Uploads and file views | Messaging entirely |
Only one row there is fatal, and it's the one that should be. That's the property the architecture is built for: the number of things that can take the product down is one, and it's the database.
Go read it — it's all open.
The realtime server, the permission helpers, the idempotency handling. Clone the repo and have a look, or start a free workspace and try killing your wifi.
The generalisable bit
If you're building something realtime, the transferable idea is this: decide which of your transports is load-bearing, and make sure it's the reliable one.
Websockets are excellent at pushing events and mediocre at being the only way to talk to your server. They drop on network changes, corporate proxies eat them, and mobile connections churn constantly. Sending writes over a transport with those properties means inheriting all of them.
HTTP for writes, websockets for events, idempotency keys so retries are safe, and optimistic rendering so it still feels instant. The socket becomes a performance optimisation, and performance optimisations are allowed to fail.