2 min read
retries make chat messages twins
graphql architecture backend

A chat message send looks like the simplest mutation in any product. It is also the one users run from a phone, on transit wifi, where the request succeeds and the response is lost on the way back. The client sees a timeout, retries like it should, and the conversation now has the same message twice.

You cannot fix this client-side. The client genuinely does not know whether the first attempt landed. That is what a timeout means. Deduplicating by content and timestamp server-side is guesswork with false positives: “ok” sent twice in a row is two real messages.

The fix is old and boring: an idempotency key.

the shape

The client generates a uuid per logical send, before the first attempt, and sends it with the mutation. Retries of the same send carry the same key. A new message the user actually typed again gets a new one. The server puts a uniqueness constraint on the key and, on conflict, returns the already-created message as a success.

That last clause is the part that makes it work: the retry must get back the original message rather than an error. To the client, a retry that hit the constraint is indistinguishable from a slow first attempt succeeding, and the client’s happy path handles both.

the details

Where the actual work lives, in the order I hit it:

The key belongs to the logical send. Generate it when the user hits send, store it with the outgoing-message state, reuse it across every retry of that message. Generating a fresh key per http attempt makes the whole thing a no-op.

Scope the constraint. The uniqueness should be per sender or per conversation rather than global, so a key collision between users (or a buggy client reusing keys) cannot block unrelated conversations.

Side effects have to dedupe too. Creating the message row once is not enough if notifications, unread counters, and activity feeds fire on every attempt. Everything downstream of the create has to fire only when a row was actually inserted.

Replies need the same treatment as messages. Any write the client retries needs a key. Adding the key to one mutation and leaving its sibling without one moves the duplicate to the sibling.

Fuzzy content matching and time-window deduplication both produce false positives in production. The unique constraint does not.