
A live-chat message has a very small patience budget. If a visitor sends a question and nothing moves for a few seconds, the product already feels suspicious. Maybe the message failed. Maybe nobody is there. Maybe the widget is just another contact form with a nicer button. That feeling matters, so we treat message delivery as part of the product experience, not as a detail hidden behind the API.
The main path in Convor is deliberately plain. The widget or dashboard sends a request to the Fastify API. The server checks the caller, checks the organization, sanitizes the message, writes it to PostgreSQL, and then publishes an event to the real-time layer. The order is important. The database is the source of truth. WebSockets make the update fast, but they do not become the place where the conversation lives.
Why polling is not enough
Polling is tempting because it is easy to build. Every few seconds the browser asks: is there anything new? That works for small notification badges. It is a poor fit for a conversation. Short intervals waste requests. Long intervals make replies feel late. The worst part is inconsistency. One tab may catch a message almost immediately while another waits for the next polling window. A support chat should not depend on luck like that.
Convor uses Centrifugo for persistent connections. Operators subscribe to organization and conversation channels. Visitors subscribe only to their own conversation channel. When the API publishes a message event, Centrifugo pushes it to the connected clients that are allowed to receive it. The dashboard does not have to notice a change by asking again and again. It receives the change as soon as the server has saved it.
The work around the message
Sending a message starts other work too. Unread counters change. Automation rules may need to run. An outbound webhook may be queued. Sentiment or translation jobs may be scheduled. Operators may get push notifications. None of that should delay the visitor from seeing the reply land in the chat. The message path stays short, and the slower work goes through queues or separate services.
Typing indicators follow a lighter path because they are temporary. A typing signal is useful for a few seconds and then worthless. Read receipts sit closer to stored state, because they affect counters and what the operator sees. We keep those concepts separate. A message table should hold messages, not every tiny UI signal that happened during the conversation.
Bad networks are normal
The happy path is easy to demo. Real users switch networks, close laptops, reopen tabs, and sit behind corporate firewalls that kill idle connections. The widget has to assume that the WebSocket can disappear. When that happens, it keeps the conversation identity, reconnects, and reloads history from the API if it has to. A lost socket should not mean a lost conversation. At worst, the client catches up from PostgreSQL and continues.
This is also why channel authorization matters. A visitor cannot subscribe to a different visitor's conversation. An operator cannot receive events from another organization. The UI should enforce that, but the broker should enforce it too. Real-time systems are fast, and fast mistakes spread quickly if the boundaries are loose.
The split is simple in practice: Fastify owns validation and writes, PostgreSQL owns history, Centrifugo owns delivery to connected clients, and Redis with BullMQ absorbs work that should not sit in front of the visitor. That is the architecture we can keep extending without making every new feature part of the critical send path.
Get new posts in your inbox
No spam. Unsubscribe anytime.

