Why Our Messages Don't Get Lost or Reordered
This is not a marketing post. Every guarantee maps to auditable code, tests, and documentation in the repository. Clone it and verify for yourself — reproduction commands at the end.
“Will messages get lost? Will they arrive out of order?” — these are the first questions any technical decision-maker asks when evaluating an IM solution, and the hardest to get honest answers to. Most vendors reply with four words — “no loss, no reordering” — and leave it at that.
This article dissects IMCore’s answer layer by layer: what each layer guarantees, how it works, and how to verify it.
The one-sentence answer
Messages are persisted to the database before the server acknowledges them; within a conversation, ordering is enforced by a database-level lock; after reconnection, clients catch up precisely via cursor-based backfill.
Now let’s unpack each layer.
Layer 1: Messages don’t get lost — write to disk first, then acknowledge
The most common way messages get lost
Most IM systems handle sends like this:
Client sends → Server receives → Push to recipients → Return "success"
The problem: what if the server crashes after pushing to the recipient but before returning “success”? The client thinks the send failed and retries — but the recipient already got a “ghost message.” Conversely, if the server crashes before pushing, the client gets a timeout — but the message may already be in a memory queue, never to be seen again.
IMCore’s approach: synchronous idempotent insert
Client sends
↓
Server generates UUIDv7 message ID
↓
Synchronous INSERT into PostgreSQL (idempotent: retries don't duplicate)
↓ ←—— Data is on disk. Power loss here doesn't lose the message.
Push to online recipients
↓
Return "success" to sender
The critical detail: INSERT happens before ACK. The moment the client receives “success,” the message is already in the database. Crash, restart, process killed — the message survives.
Idempotency comes from the database unique key (conversation_id, sender_id, client_dedup_id) — a client retrying the same message (network retry, reconnection resend) hits INSERT IGNORE, which doesn’t create a second row and returns the original message_id.
-- PostgreSQL
INSERT INTO dm_messages (conversation_id, message_id, sender_id, client_dedup_id, payload, ...)
VALUES (?, ?, ?, ?, ?, ...)
ON CONFLICT (conversation_id, sender_id, client_dedup_id) DO NOTHING;
-- MySQL
INSERT IGNORE INTO dm_messages (conversation_id, message_id, sender_id, client_dedup_id, payload, ...)
VALUES (?, ?, ?, ?, ?, ?);
On resend, RowsAffected = 0 — the server looks up the original message_id and returns inserted=false, so the client knows this was a retry and doesn’t need to re-render it.
Layer 2: No reordering — same-conversation ordering enforced by a database lock
The classic reordering scenario
Alice and Bob send messages to the same group chat almost simultaneously:
Time T1: Alice's INSERT gets id=100, but the transaction hasn't committed yet
Time T2: Bob's INSERT gets id=101, and commits first
Time T3: A recipient pulls messages with id > 99 → sees 101, can't see 100
Time T4: Recipient advances cursor to 101
Time T5: Alice's transaction commits, id=100 becomes visible
Result: Recipient's next pull is id > 101 → empty. Message 100 is permanently skipped.
This isn’t hypothetical — we found and fixed this exact bug in our own codebase.
IMCore’s fix: the conversation write lock
// lib/dblib/conversation_write_lock.go
func withConversationWriteLock(ctx context.Context, key string, fn func(conn *sql.Conn) error) error {
// PG: pg_try_advisory_lock(hashtextextended(key, 0))
// MySQL: GET_LOCK(key, 0)
//
// Try-and-backoff loop: waiters hold no database connection
// Holder runs the INSERT on the same connection; lock releases after COMMIT
// → Within one conversation, "assign id" and "commit" are strictly serialized
}
Effect: within a single conversation, id assignment order == commit order. In the scenario above, Bob’s INSERT waits for Alice’s COMMIT to finish before getting its id — a recipient can never see 101 without seeing 100 first.
This isn’t a convention — it’s enforced by database-level advisory locks. We wrote a deterministic regression test (lib/dblib/conversation_write_lock_test.go) that pins this behavior: when an external holder owns the lock, the DAO’s insert must block; after release, it must complete.
Why not a centralized sequence number?
A centralized per-conversation sequence (one Redis INCR per conversation) introduces extra latency and infrastructure dependency under high concurrency. Our approach:
- Message ID = UUIDv7 (client-side cursor; lexicographic order ≈ chronological)
- Database auto-increment id = server-side total order (history pagination; naturally unique)
- Conversation write lock = guarantees id assignment order == commit order (no extra sequence number needed)
Zero additional infrastructure — pure database-native capabilities.
Layer 3: Reconnection recovery — precise cursor-based catch-up
Realtime pushes can drop (client disconnects, network jitter), but as long as the message is in the database, the client can always catch up.
The mechanism
Client's last-seen message ID: "msg-019abc..."
↓
After reconnecting, sends historyPage({ afterID: "msg-019abc..." })
↓
Server: maps UUIDv7 → database id → SELECT ... WHERE id > ? ORDER BY id ASC
↓
Returns every missing message (precise to the row, no more, no less)
Key design decision: afterID is a message the client has actually seen — not the server guessing “where should we be.” The client only trusts what it has witnessed; the server only sends what came after — no ambiguity.
SDK-level coordination
All 10+ platform SDKs automatically handle the reconnected event:
- Flush the offline queue (only after login confirmation)
- Incremental backfill per conversation via cursor
- Enforce
lastMessageSeqmonotonic guard (stale events can’t overwrite newer snapshots)
Layer 4: Verification — not “trust us,” but “run it yourself”
Zero-loss verdict
# Under sustained load, kill one instance → survivors take over → restart the killed one → compare sent == persisted
scripts/chaos/graceful_drain.sh
scripts/chaos/verify_no_loss.sh --report <loadtest-report> --room <room-id>
Ground truth is database row count, not delivery counters — sent == COUNT(*) settles it in one comparison.
Session recovery matrix
Nine automated scenarios (SR-01 through SR-09) cover: initial fetch racing with realtime pushes, reconnection backfill, multi-device concurrent read marking, conversation cleanup after group removal, concurrent senders with interleaved commits (our most recent addition) — each running continuously in CI.
Load test reproduction
# 4C8G cloud ECS, 10,000 concurrent connections, 200 msg/s, 50 rooms
# All steps: 0 errors, 0 drops, 0 slow-consumer closes, p99 < 200ms
# The saturation tool automatically provisions and destroys ECS instances
ALICLOUD_REGION=cn-hangzhou LT_SAT_STEPS=5000,6000,8000,10000 \
bash scripts/loadtest_saturation.sh
Honest boundaries
The following scenarios are not covered by these guarantees:
| Scenario | Why | Mitigation |
|---|---|---|
| Recipient online but app killed | Push reaches the OS but not the app | Offline push (APNs/FCM) + reconnection backfill |
| Extreme message latency | Database or Redis unavailable | Degradation paths (DB direct-write when Redis is down) |
| Cross-conversation global ordering | By design, only within-conversation ordering is guaranteed | Each conversation is independently ordered (the correct semantics for IM) |
The debugging story: how we found the reordering bug
In October 2026, while comparing cloud load-test data between old and new code, we noticed the 10,000-connection p99 had climbed from 139ms to 262ms. Using automated bisection across 561 commits, we pinpointed the culprit: a “performance optimization” that replaced the per-member conversation-state flow with whole-room batched pipelines — a huge win in local benchmarks, but in production topology the mega-pipelines head-of-line blocked the Redis event loop.
The lesson (for us, and for you): performance regressions aren’t scary — what’s scary is not having the instruments to detect them. We spent a full day building the complete diagnostic chain from A/B comparison to automated bisection — that chain is now part of the product.
Summary
| Guarantee | Implementation | Verification |
|---|---|---|
| No message loss | Synchronous idempotent insert (persisted before ACK) | Zero-loss verdict script |
| No duplicates | Database unique key + INSERT IGNORE |
Idempotent resend tests |
| Within-conversation ordering | Conversation write lock (id order == commit order) | Mutual-exclusion regression gate |
| No gaps after disconnect | Cursor-based precise backfill (afterID) |
9-scenario recovery matrix |
| End-to-end trust | Full-stack chaos + load testing + CI gates | Reproducible commands |
IMCore ships as binaries by default, with source access available under license. If you have questions about any layer described here, talk to us — we’ll answer with the exact code path and the test that pins it, not marketing copy.