Idempotent WebSocket Message Processing #
The user pressed Send once. The message appears twice — once from the optimistic local write, once because the client resent it after a reconnect and the server had already processed the first copy. Or the accounting variant, which is worse: a “credit 50 tokens” message replayed after a node restart credits 100.
Any real-time system that retries — and every system that survives disconnection retries — delivers some messages more than once. Deduplication is not a nicety bolted on afterwards; it is the property that makes retry safe in the first place.
Root cause #
At-least-once delivery is the only guarantee a distributed system can offer cheaply. Exactly-once, in the strict sense, requires distributed consensus on every message and costs far more than it is worth for real-time traffic. So the practical design is at-least-once delivery plus idempotent processing, which together behave as exactly-once from the outside.
Duplicates enter through four doors, and all four are normal operation rather than bugs:
Client retry after reconnect. The client sent a message, the socket dropped before the ack arrived, and it cannot know whether the server processed it. Resending is the correct behaviour — the alternative is losing the user’s action.
Server replay on resume. A resumed session replays buffered messages, and the client may already have applied some of them, as covered in resuming WebSocket sessions after reconnect.
Redelivery from the bus. A consumer that read from a Redis Stream and crashed before acknowledging will reclaim and reprocess those exact entries on restart.
Cross-region replication. A message forwarded between regions can arrive twice if a link recovers mid-transfer.
The unifying fix is that every message carries an identity assigned by its originator, and every side effect is guarded by that identity.
Resolution #
Deduplicate with an atomic set-if-absent against a bounded window, and store the original result so a duplicate can be answered rather than ignored.
import Redis from 'ioredis';
const DEDUP_WINDOW_SEC = 3600; // how long a message id is remembered
const redis = new Redis(process.env.REDIS_URL!);
interface Incoming { id: string; t: string; d: unknown; }
type Result = { ok: true; seq: number } | { ok: false; reason: string };
const dedupKey = (userId: string, id: string) => `dedup:${userId}:${id}`;
export async function processOnce(
userId: string,
msg: Incoming,
handler: (m: Incoming) => Promise<Result>,
): Promise<Result> {
const key = dedupKey(userId, msg.id);
// Atomic claim. NX means "only if absent", so exactly one concurrent attempt
// wins even if the duplicate arrives on another node at the same moment.
const claimed = await redis.set(key, 'pending', 'EX', DEDUP_WINDOW_SEC, 'NX');
if (!claimed) {
// Someone already claimed this id. Either it finished — in which case we
// return its stored result — or it is still running, and we wait briefly.
const stored = await redis.get(key);
if (stored && stored !== 'pending') return JSON.parse(stored) as Result;
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry && retry !== 'pending') return JSON.parse(retry) as Result;
// Still pending: tell the client to retry rather than double-applying.
return { ok: false, reason: 'in_progress' };
}
try {
const result = await handler(msg);
// Overwrite the claim with the actual result, keeping the same expiry, so a
// later duplicate is answered identically instead of being re-processed.
await redis.set(key, JSON.stringify(result), 'EX', DEDUP_WINDOW_SEC);
return result;
} catch (err) {
// A failed attempt must NOT be remembered as done, or a legitimate retry
// would be swallowed and the message lost for good.
await redis.del(key);
throw err;
}
}
The del in the catch block is the line people omit and then spend a day debugging. If a handler throws — a transient database error, a timeout — and the claim survives, the client’s retry is deduplicated against a message that never actually succeeded, and the action is silently lost. Failure must release the claim.
Where possible, prefer naturally idempotent operations over a dedup store. Setting a value is idempotent; incrementing is not. Inserting with a unique key is idempotent (the second insert conflicts); appending is not. Reshaping an operation so the database enforces uniqueness is more robust than any application-level window, because it cannot expire:
-- The dedup window can lapse; a unique constraint cannot.
INSERT INTO messages (client_msg_id, room_id, user_id, body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (client_msg_id) DO NOTHING
RETURNING seq;
Verification #
Duplicates are rare in development and routine in production, so force them.
# Send the same id twice; the second must be answered, not applied
npx wscat -c ws://localhost:8080/ws \
-x '{"id":"7c2f","t":"room.post","d":{"body":"hello"}}' \
-x '{"id":"7c2f","t":"room.post","d":{"body":"hello"}}'
# Concurrent duplicates on two connections — the NX claim must let exactly one win
seq 1 2 | xargs -P2 -I{} npx wscat -c ws://localhost:8080/ws \
-x '{"id":"9a10","t":"room.post","d":{"body":"race"}}'
# Confirm the window is bounded rather than growing forever
redis-cli --scan --pattern 'dedup:*' | wc -l
Then assert on the data, not the logs: after both sends, the room must contain exactly one message with that id, and the client must have received two acknowledgements with the same sequence number. Track ws_duplicates_suppressed_total in production — a non-zero, stable count means clients are retrying correctly and the guard is working, while a sudden spike usually means a reconnect storm or a bus redelivery worth investigating on its own.
Operational checklist #
FAQ #
How long should the dedup window be? #
Longer than the longest interval over which a client might retry, which is bounded by your reconnect backoff ceiling plus the outbox lifetime — for most apps that is minutes rather than hours, so a one-hour window is comfortable. Beyond that, memory cost grows without reducing risk, and anything that could still be retried after an hour belongs behind a database constraint instead, which never expires.
Is exactly-once delivery possible? #
Not over an unreliable network, in the strict sense — the sender can never distinguish “the message was lost” from “the acknowledgement was lost”, so it must either risk losing it or risk sending it twice. What is achievable, and what everyone means in practice, is at-least-once delivery combined with idempotent processing, which produces exactly-once effects. That is the design this page implements.
Should the id come from the client or the server? #
The client, always. The whole point is that the same id survives a retry, and only the originator knows that two attempts are the same attempt. A server-generated id is assigned per delivery, so a retry gets a fresh one and deduplication becomes impossible. Use a UUID generated at the moment of the user action, stored alongside the message in the outbox.
What about ordering — does deduplication break it? #
No, they are orthogonal concerns. Deduplication decides whether a message is applied; ordering decides in what sequence. You need both, and they use different fields: an id for identity and a sequence or version for order, which is why the envelope in message framing and serialization carries both.
Does this need Redis, or can it be in-process? #
In-process works only if the same node always handles a given user’s retries, which is exactly what a reconnect breaks — the client will usually land somewhere else. Use a shared store, or push the guard down into the database with a unique constraint, which has the advantage of surviving a Redis failure and never expiring.
What about idempotency for outbound messages? #
The same idea in the other direction: give every outbound message a stable id so a client can recognise a replayed one and skip it. Combined with the per-entity version check from handling out-of-order WebSocket messages, that makes server replay safe to apply without the client needing to know whether a message is new or repeated.
Related #
- Message Delivery Guarantees — the delivery semantics this makes safe.
- At-Least-Once WebSocket Delivery with Acknowledgements — the ack and retry loop that produces the duplicates.
- Queueing WebSocket Messages While Offline — where the client-generated id is created and persisted.
- Resuming WebSocket Sessions After Reconnect — replay, the other major source of duplicates.
- Redis Streams vs Pub/Sub for WebSocket Fan-Out — bus-level redelivery and consumer-group reclaim.
Back to Message Delivery Guarantees