Engineering

How realtime works when the socket isn't the point

Fluid Chat treats websockets as an accelerator, not a dependency: writes go over HTTP, Socket.IO relays events, Redis fans out only when you scale.

· 4 min read

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

  1. 1The client renders it optimistically. Your message appears immediately, with a client-generated idempotency key attached.
  2. 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.
  3. 3The app notifies the realtime server. A single process posts directly over HTTP; multiple processes publish to Redis instead.
  4. 4The realtime server fans out to the clients subscribed to that conversation's room, and only those clients.
  5. 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:

Single process
client --POST--> app ---> postgres                  |                  +--HTTP--> realtime ---> subscribed clients
Multiple processes
client --POST--> app-2 ---> postgres                   |                   +--publish--> redis                                   |                    +--------------+--------------+                    v                             v               realtime-1                    realtime-2                    |                             |               subscribed clients           subscribed clients

That'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

FailureWhat you loseWhat still works
Realtime server downLive updates, typing, presenceSending, reading, search, uploads, everything else
Redis down (multi-process)Cross-process event deliveryAll writes; same-process clients still update live
Postgres downEverythingNothing. It's the database.
Worker downScheduled sends, reminders, retention, exportsAll interactive use
Object storage downUploads and file viewsMessaging 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.

FAQ

Questions people actually ask.

What happens if the websocket server goes down in Fluid Chat?

The app keeps working. Writes go over HTTP rather than the socket, so you lose live updates, typing indicators and presence, but sending, reading, searching and uploading all continue. Realtime is treated as an accelerator rather than a dependency.

Why send chat messages over HTTP instead of websockets?

Reliability and honesty. HTTP requests have status codes, retries and a clear failure model, while websockets drop on network changes and get eaten by corporate proxies. It also means the web client uses the same public API you can, rather than a private socket protocol.

How does Fluid Chat prevent duplicate messages?

Every send carries a client-generated idempotency key. If a request times out and the client retries, the server recognises the key and returns the original message rather than creating a second one, so retries are always safe.

When do I need Redis for realtime?

Only when you run more than one app or realtime process. With a single process the app posts events directly to the realtime server over HTTP. Once you scale out, Redis pub/sub fans events across processes so a message written through one reaches clients connected to another.

How does Fluid Chat keep private channels private in realtime?

Sockets join a conversation's room only after the server authorizes that user to read it, so events are delivered to authorised subscribers rather than broadcast and filtered client-side. The realtime layer and the API share the same workspace-scoped permission helpers, including guest scoping.

Something not covered? Open an issue.

Realtime that fails gracefully.

One fatal dependency, and it's the database. Read the source, or start a free workspace and see how it behaves on bad wifi.