Back to blog
FILE 0xB2·TWO BACKENDS, ONE CHAT ROOM

Two backends, one chat room

August 30, 2026 · architecture, realtime, debugging, homelab

Two people, two personal assistant apps. Different codebases, different databases, different owners, built months apart with no intention of ever talking to each other. The ask was a group thread: both humans and both assistants in one conversation, each person still using their own app.

The obvious version of this is a new service in the middle — a message bus, a shared room table, a schema both sides agree on. That's a third thing to build, host, and keep alive, and it would have meant rewriting the chat surface in both apps against a new message model. I didn't want a third thing.

A room is just a conversation with a shared id

What I built instead is a mirror. A "room" is one ordinary conversation row in each backend. Both rows carry the same room_id. Neither side owns the room; each side owns its own copy of it.

When a message lands in a room on either side, that side POSTs it to the other over a single authenticated endpoint. The receiving side writes it into its local conversation as a message row with role="user" and a new sender column naming the author.

@app.post("/room/relay")
def relay(req):
    verify_peer(req)                    # per-peer shared secret
    room = conversations.by_room_id(req.room_id)
    if not room:
        return 404
    if messages.exists(room.id, origin_id=req.origin_id):
        return {"ok": True}             # idempotent; we've seen this one

    messages.insert(
        conversation_id=room.id,
        role="user",                    # <- the whole trick
        sender=req.sender,              # <- the only new column
        origin_id=req.origin_id,
        body=req.body,
    )
    notify_subscribers(room.id)         # existing transport, untouched
    return {"ok": True}

That's the entire integration surface. One endpoint, one new column.

The reason it's worth writing down is what it bought. Because a foreign message is stored as an ordinary user-role row in an ordinary conversation, every transport I already had carried group chat for free: the websocket subscription, mobile push, the background pollers, the web UI, the history endpoint, unread counts. Zero of them needed to learn a new message type. Don't invent a new kind of message. Tag the one you have.

role is not identity

Here's the assumption every single-user chat app makes without noticing: role == "user" means me, and role == "assistant" means not me. Role is doing double duty as authorship, and it works perfectly right up until there is a second human in the thread.

Then it breaks everywhere at once, in a single afternoon: bubble alignment, sender captions, avatar colors, quoted replies rendering "You said:" over somebody else's sentence, unread counts treating another person's message as your own and never incrementing. Every one of those is the same bug wearing a different hat.

The fix is boring and mechanical. Attribute by author, fall back to role only for rows written before the column existed:

function isMine(msg, me) {
  if (msg.sender) return msg.sender === me;  // attribute by author
  return msg.role === "user";                // legacy rows only
}

Then go find every place that tested role === "user" and make it call that. There were more of them than I expected, which is the point — the conflation had spread because it had always been true.

The bug that was actually worth the post

Optimistic send. The client draws your bubble locally the instant you hit enter, then binds it to the real server row when the round trip comes back, so the message doesn't visibly jump. Standard, and it had worked fine for a year.

The merge matched an unbound local bubble to an incoming server row by body text and a time window. In a single-user thread that's safe: nobody else can produce a message with your exact words.

In a room, somebody else can. The other person quoted me — same words, back into the thread, seconds later. My still-unbound optimistic bubble looked at that incoming row, saw a text match inside the window, and adopted their server id. The bubble flipped from the right side of the screen to the left under my finger. In the other direction, the same match ran as a dedupe and silently deleted a bubble for a message the server had never received.

The guard is one line, and it's the same idea as the section above:

for (const row of serverRows) {
  if (row.id === pending.boundId) { bind(pending, row); continue; }
  if (pending.boundId) continue;                  // already bound
  if (!isMine(row, me)) continue;                 // <- the guard
  if (row.body === pending.body && withinWindow(row, pending)) {
    bind(pending, row);
  }
}

An unbound optimistic bubble may only bind to — or be deduped against — a row whose author is you. Anything else is somebody else's message and is none of its business.

Two assistants in a room will talk to each other forever

Put two agents in a thread where each one answers every message, and you get a conversation that never ends and costs money the whole time. I watched about six turns of two assistants being extremely polite to each other before I killed it.

Two guards, both cheap:

MAX_HOPS = 2

def should_answer(msg, me):
    if msg.hops >= MAX_HOPS:
        return False            # relayed agent chatter dies here
    return addressed_by_name(msg.body, me)

Mention-gating is the one that matters, and it's a product decision as much as a safety one. A group chat is people talking. An assistant that responds to every line is a third wheel. Address it by name or it stays quiet.

The hop counter is the backstop: relayed agent messages carry a counter that increments on each hop, with a hard cap. Mention-gating should already prevent a loop; the cap is there for when it doesn't.

The part I'd rather not admit

The realtime push layer's schema is fixed, and I couldn't extend it to carry the new sender field without a migration I didn't want to do today. So clients don't trust the pushed payload for room messages — the push arrives and they refetch the tail of the conversation over the normal history endpoint.

It works, and it works for a defensible reason: the push is a signal, not the data. But it's an extra round trip on every message in a room, and if the room ever gets busy it'll show. Naming it beats hiding it.

What I'd do differently

Add the sender column first, before there's any second party, and make every read path go through an isMine() helper from day one. The relay was an afternoon. Untangling role-as-identity across the client was the rest of the week, and none of that work was interesting — it was just finding the same wrong assumption in twenty places.

The other thing: the optimistic-merge bug existed the moment a second human could write to the thread, but I found it by accident, because the other person happened to quote me. A test that posts a foreign message with identical body text while a local send is in flight would have caught it in seconds. That test now exists. It should have existed before the feature did.