How to Add Real-Time Chat to an AI-Built App
A decision framework for choosing websockets, a managed realtime service, or polling, plus the minimum messages, participants, and read-receipt schema to build human-to-human chat.
Real-time chat between users needs three things working together: a way to push new messages to open clients without a page refresh, a schema that stores messages and tracks who has read what, and a decision about how much infrastructure you want to run yourself. How to add real-time chat to an AI-built app usually comes down to picking between raw websockets, a managed realtime service, and simple polling, then building a messages table, a participants table, and a read-receipts table on top. The right choice depends on expected message volume and how much backend you want to own, not on which option sounds most modern.
The three ways to add real-time chat to an AI-built app
Every chat feature, from a two-person direct message thread to a group channel, is solving the same problem: get a new row from the database in front of the right users as fast as reasonably possible. There are three practical ways to do that.
Websockets you run yourself. A persistent, bidirectional connection between client and server. The server pushes a message the instant it is written. This gives you the lowest latency and the most control, and it also means you own connection scaling, reconnect logic, and message ordering.
A managed realtime or pub-sub service. You subscribe to a channel or a database table, and the provider pushes changes to connected clients over a connection it manages. You write less infrastructure code, but you are trusting someone else's uptime and pricing model, and you have less control over connection limits and delivery guarantees.
Simple polling. The client asks "anything new?" every few seconds. No persistent connection, no extra infrastructure, and it is trivial to build and debug. It is slower and it costs more database reads as your user count grows, but for low-volume chat it is often the right call.
Approach | Latency | Infra you own | Best for | Main cost driver |
|---|---|---|---|---|
Websockets (self-managed) | Near-instant | High: scaling, reconnects, load balancing | Steady conversational traffic, custom routing | Engineering time and server capacity |
Managed realtime service | Near-instant | Low: subscribe to a channel or table | Most direct-message and group chat features | Connections or messages delivered |
Polling | 2 to 10 second delay, typically | Very low: a scheduled request | Low-volume chat, validating demand | Database reads per active user |
Choosing by message volume and infrastructure appetite
The decision framework is simpler than most guides make it sound. Weigh two variables: expected peak messages per minute, and how much infrastructure you actually want to operate.
If you expect fewer than a handful of messages per minute across your whole user base, a support inbox or an internal tool used by a small team, polling every three to five seconds is fine. It is easy to reason about, easy to cache, and it fails safely. Nobody notices a three-second delay in a low-traffic thread.
If you expect steady conversational traffic, direct messages, group chats, anything where people are actively typing back and forth, you want push delivery. The question becomes whether you run your own websocket server or lean on a managed realtime service. If your database already supports change-data-capture style subscriptions, a managed service usually wins on time to ship: push delivery without standing up a stateful connection layer. If you need custom routing logic, presence beyond what the provider exposes, or you already run a stateful backend for other reasons, rolling your own websocket layer stops being extra work and becomes the path of least resistance.
If you expect bursty, high-volume traffic, thousands of concurrent connections, live auctions, multiplayer-adjacent features, plan for websockets you control from the start. Managed services can handle real scale, but at that volume you want to know exactly how connections are load balanced and what happens when a node falls over, which usually means owning the layer or picking a provider you have load tested yourself.
A rule of thumb that holds up in practice: start with polling if you are validating whether people want the chat feature at all, move to a managed realtime service once you know they do, and only build your own websocket infrastructure when you have a specific reason a managed service cannot meet, like custom presence logic or connection volume that changes your cost math.
There is also a cost dimension worth naming. Polling scales database load with active user count regardless of how much anyone is saying, which gets expensive quietly. Managed services usually price on connections or messages delivered, tracking actual usage. Self-hosted websockets shift the cost to engineering time spent on scaling and on-call, a real cost even without a monthly invoice.
The minimum schema: messages, participants, read receipts
Whichever delivery mechanism you choose, the schema underneath it barely changes. Here is the minimum set of tables that supports one-to-one and group chat, message history, and read receipts.
Messages table
Every message belongs to a conversation and an author, and carries a timestamp you can sort on.
create table messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references conversations(id),
author_id uuid not null references users(id),
body text not null,
created_at timestamptz not null default now()
);
create index messages_conversation_created_idx
on messages (conversation_id, created_at);Index conversation_id and created_at together. Almost every query you run is "give me the last N messages for this conversation, ordered by time," and that index makes it cheap regardless of how you deliver the update.
Participants table
A conversation needs to know who is in it, separate from the messages themselves. This is also where you will eventually hang permissions, mute state, and per-user settings.
create table conversation_participants (
conversation_id uuid not null references conversations(id),
user_id uuid not null references users(id),
joined_at timestamptz not null default now(),
muted boolean not null default false,
primary key (conversation_id, user_id)
);The composite primary key stops duplicate participant rows and gives you a fast lookup for "which conversations is this user in," which is the query your inbox view runs constantly.
Read receipts
Read state is not a property of the message. It is a property of the relationship between a user and a conversation, or a user and a specific message if you need per-message receipts. The simplest version tracks the last message a user has seen per conversation.
create table conversation_reads (
conversation_id uuid not null references conversations(id),
user_id uuid not null references users(id),
last_read_message_id uuid references messages(id),
last_read_at timestamptz not null default now(),
primary key (conversation_id, user_id)
);This single-row-per-user-per-conversation design is deliberately boring. It answers "how many unread messages does this user have" with one indexed comparison instead of a scan, and it updates with a single write: bump last_read_message_id when the user opens the thread.
If your product needs per-message read receipts, group chats where you show which of five people have seen a message, you add a separate message_reads table keyed on message_id and user_id instead of collapsing it into the participant row. Only build that when a feature actually requires it. Most chat products ship for months on the conversation-level version above.
One detail that trips people up regardless of delivery method: message ordering and duplicate sends. Client clocks are not reliable, so sort by a server-assigned timestamp or a monotonically increasing ID, never one the client generated. Because retries happen, give the client an idempotency key to send with each message so a retried send does not create a duplicate row. Both are schema and client-logic issues, not tied to websockets, polling, or a managed service.
Wiring chat into an app that already has accounts and a database
Chat does not live on its own. It sits on top of an accounts system, since every message needs an author and every conversation needs a participant list drawn from real users, not anonymous sessions. If that layer is not settled yet, lock it down first, since the participants table above assumes stable, unique user IDs. The accounts system chat needs to hang off of walks through that part.
The messages table is also, in practice, the fastest-growing table in most consumer-facing apps. Pagination, indexing, and how well your database handles frequent small writes matter more here than almost anywhere else in your schema. Picking a database that handles message history well is worth reading before you commit, since chat workloads expose weaknesses a typical CRUD app never hits.
None of this needs to happen before a first version ships. Whether you need a backend at all covers when a backend earns its complexity and when a managed database with a realtime add-on gets you further than expected. For a two-person direct message feature bolted onto an existing app, you often do not need a custom server at all.
If you are still deciding what your app is built on in the first place, the full guide to building an app with AI is the place to start before layering chat on top.
This is chat between people, not a chatbot
Worth separating clearly: everything above describes messaging where two or more humans talk to each other and the app's job is delivery and storage. That is a different problem from an AI assistant embedded in your product that responds to a single user. Human-to-human chat versus a chatbot layered on top lays out the distinction, but the short version is that a chatbot layer needs a completion endpoint and context management, while human-to-human chat needs the delivery and schema work covered here. Some products need both, a support thread where a bot answers first and a human takes over, but they are built and stored differently.
Questions people ask
Is websockets or polling better for a chat feature?
Neither is universally better. Polling is simpler to build and adequate for low message volume, roughly one message every few seconds or slower per active conversation. Websockets deliver messages instantly and handle high, steady traffic far more efficiently, at the cost of running and monitoring a persistent connection layer. Pick based on expected volume, not on what feels more modern.
Do I need websockets for a simple direct message feature?
Usually not on day one. A managed realtime service or even three-second polling gets a direct message feature working correctly, and you can measure real usage before investing in your own connection infrastructure. Switch to self-managed websockets only when you hit a specific limit, like connection volume or routing logic, a managed option cannot meet.
How do you store read receipts in a chat database?
Track the last message ID each user has read per conversation, in the participants or membership table rather than on the message itself. That gives you unread counts cheaply. Only add a separate per-message read table if you need to show exactly which individual users have seen a specific message, which most products do not need at launch.
What is the difference between a messages table and a conversations table?
The conversations table represents the thread itself, its participants and metadata. The messages table holds the individual rows of content, each pointing back to a conversation and an author. Keeping them separate is what lets you support group chats, add participants later, and query message history without scanning unrelated conversations.
Can I add real-time chat without building a custom backend?
Often yes. A managed database with a built-in realtime or subscription feature, paired with your existing accounts system, covers direct messaging and small group chat without a server you write yourself. You are more likely to need custom backend logic for message moderation, complex permissions, or very high traffic than for the core send-and-receive loop.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


