Build1 publisher3 min readPublished
Confirming on arrival let a messenger's server delete messages the client never decrypted
An encrypted messenger shipped offline delivery behind 216 passing tests and delivered nothing. Only one of the four bugs needed a real machine to reproduce; the other three needed an assertion about leftover state.
The Engineer · Build desk

What happened
- A developer building an end to end encrypted messenger shipped offline message delivery behind 216 passing tests, including integration tests against a real Postgres, and the feature delivered nothing.
- On the deployed build the server handed over held messages the instant the socket opened, while the client was still loading its decryption keys from browser storage, so the messages were dropped.
- Because the client confirmed each message on arrival, the server deleted held messages before the app had decrypted or stored them, and those messages were gone from both sides.
- Only messages delivered from storage were ever confirmed, so live-delivered messages were stored and never deleted, leaving a server side copy of each active conversation until a weekly sweep ran.
- Deleting a chat removed its messages but left its encryption keys, so re-adding the same contact resumed a conversation the other end had discarded and nothing could be decrypted.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Reproducing the startup race needs a harness that makes the key read slow, because a fixture that resolves immediately cannot produce the window at all. Fixing that means changing how the suite runs.
- decision Anyone building at-least-once delivery has to pick which client event triggers the upstream delete, and that pick decides whether a throwing handler loses the message or gets it again on the next connection.
- exposure Users of the shipped build were relying on a relay that keeps nothing longer than it must, while the delete that backed the claim ran only on the from-storage delivery path.
The first fix is two lines swapped: load the saved state, then open the socket [8]. That closes the window by making the key read a precondition of being connected. It also puts browser storage on the path to going online, so a slow read now delays the socket [22].
That bug is the only one of the four the author attributes to real I/O timing. In the tests the key load was effectively instant, and the gap between connected and ready to decrypt existed only on a real machine doing real I/O [7]. "if your test setup completes instantly and production does not, you are not testing the same system," the author wrote in a dev.to post [9].
The second bug is about which event the client acknowledges. The old handler called `show(m)` and then `confirm(m.id)`, so the confirm fired even when `show()` threw [11]. The server treats a confirm as permission to delete the held copy [10]. "Arrival is not delivery," the author wrote [13]. The new handler confirms only when `handled(m)` is true [11]. There is one deliberate exception: a message belonging to a conversation whose keys are gone is confirmed anyway, because asking for it again would not help [12]. Without that carve-out an unreadable message comes back on every reconnect.
The third fix is one line: `const othersPresent = room.size - 1 > 0`, read before the broadcast, with `hold(message)` running only when it is false [15]. Whether the server keeps a copy now depends on a socket being open at send time. The first bug was exactly the case where the socket was open and the message died [6]. Under the new code nothing is held for that message, so the confirm-if-handled path has nothing to replay [21].
By the author's account, a server holding copies of an entire conversation is a very different privacy claim from one holding a message for a few seconds [17]. He had integration tests running against a real Postgres [4], and found the accumulated copies by looking in the database himself [14].
Each of the other three bugs is an assertion the suite could have made. A live delivery should leave the held-message table empty. If the handler throws, the message should still be sitting on the server. Removing a chat should leave no keys behind. All three are checks on state after a sequence, and they run in the same Postgres integration harness that was already passing [4]. The author's conclusion is that the answer is not to write more tests, since he had plenty and they were all green while the feature did not work at all [19]. Three of those four bugs needed the existing tests to look at what was left over when the sequence finished [20].
What to watch
- Whether the presence-gated hold loses live messages the client fails to handle, since nothing is stored to replay.
- Whether the weekly sweep still exists now that messages are held only when the recipient is absent, and what retention window the project claims.
- Whether deleting a chat's keys is mirrored on the other end, or only on the device that removed it.