Summary
When two different inbound WhatsApp messages for the same conversation are processed concurrently, both handlers can read the same unread_count and then overwrite each other with the same incremented value.
Environment
- Repository version:
0.8.0
- Commit:
b8677608015790194c14ab7c85943ce26c7f9ef0
- Reproduction: route-level Vitest characterization using synthetic data
- Repeated executions: 3
Steps to reproduce
A focused characterization test:
- Starts with an existing conversation whose
unread_count is 0.
- Delivers two different valid inbound message payloads concurrently for the same conversation.
- Forces both handlers to read the conversation before either update is applied.
- Captures the values written to
unread_count.
npm test -- src/app/api/whatsapp/webhook/route.unread-race-repro.test.ts
No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
Two distinct inbound messages should result in an unread count of 2.
Actual behavior
Both handlers write 1, producing an effective final count of 1:
conversationReads: 2
unreadWrites: [1, 1]
effectiveFinalUnreadCount: 1
expectedFinalUnreadCount: 2
The result is identical in three consecutive focused test runs.
Technical evidence
The webhook loads a conversation snapshot before writing the update:
|
// Find or create conversation |
|
const convResult = await findOrCreateConversation( |
|
accountId, |
|
configOwnerUserId, |
|
contactRecord.id |
|
) |
|
if (!convResult) return |
|
const conversation = convResult.conversation |
It then writes the absolute expression (conversation.unread_count || 0) + 1:
|
// Update conversation |
|
const { error: convError } = await supabaseAdmin() |
|
.from('conversations') |
|
.update({ |
|
last_message_text: contentText || `[${message.type}]`, |
|
last_message_at: new Date().toISOString(), |
|
unread_count: (conversation.unread_count || 0) + 1, |
|
updated_at: new Date().toISOString(), |
|
}) |
|
.eq('id', conversation.id) |
|
|
|
if (convError) { |
|
console.error('Error updating conversation:', convError) |
|
} |
Webhook deliveries are processed after acknowledgement and can execute concurrently:
|
// Process AFTER the response so we ack Meta within their ~20s timeout |
|
// (a slow ack triggers Meta retries + duplicate inserts), while still |
|
// guaranteeing the work runs to completion. |
|
// |
|
// This MUST use `after()` rather than a detached `processWebhook(body)` |
|
// promise: on serverless platforms (we run on Vercel) the function can |
|
// be frozen or terminated the moment the response is sent, so a floating |
|
// promise's DB writes are not guaranteed to finish. That dropped a |
|
// non-deterministic *subset* of inbound messages — contacts/conversations |
|
// were created but the message insert never landed, leaving conversations |
|
// that show in the inbox with an empty thread, and no logs to explain it |
|
// (see issue #301). `after()` hands the callback to the runtime, which |
|
// keeps the function alive until it resolves (within the route's |
Impact
The inbox can undercount unread customer messages. Operators may see a lower unread badge than the number of messages actually waiting, and a conversation can appear fully reviewed before every inbound message has been handled.
Scope and limitations
This reproduction confirms the lost-update interleaving at the route-logic/unit-integration level. Supabase, Meta, and downstream services are mocked. The barrier deliberately creates a valid concurrent schedule; it does not estimate how frequently the race occurs in production.
Suggested acceptance criteria
- Concurrent inbound messages increment unread state without lost updates.
- The update uses a database-side atomic operation or a transaction/RPC with equivalent concurrency guarantees.
- A regression test covers two concurrent messages and existing non-zero unread counts.
- Read/reset behavior remains correct after the atomic increment change.
Summary
When two different inbound WhatsApp messages for the same conversation are processed concurrently, both handlers can read the same
unread_countand then overwrite each other with the same incremented value.Environment
0.8.0b8677608015790194c14ab7c85943ce26c7f9ef0Steps to reproduce
A focused characterization test:
unread_countis0.unread_count.No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
Two distinct inbound messages should result in an unread count of
2.Actual behavior
Both handlers write
1, producing an effective final count of1:The result is identical in three consecutive focused test runs.
Technical evidence
The webhook loads a conversation snapshot before writing the update:
wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 586 to 593 in b867760
It then writes the absolute expression
(conversation.unread_count || 0) + 1:wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 690 to 703 in b867760
Webhook deliveries are processed after acknowledgement and can execute concurrently:
wacrm/src/app/api/whatsapp/webhook/route.ts
Lines 193 to 205 in b867760
Impact
The inbox can undercount unread customer messages. Operators may see a lower unread badge than the number of messages actually waiting, and a conversation can appear fully reviewed before every inbound message has been handled.
Scope and limitations
This reproduction confirms the lost-update interleaving at the route-logic/unit-integration level. Supabase, Meta, and downstream services are mocked. The barrier deliberately creates a valid concurrent schedule; it does not estimate how frequently the race occurs in production.
Suggested acceptance criteria