Skip to content

[BUG] Concurrent inbound WhatsApp messages can lose unread-count increments #369

Description

@iuryeng

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:

  1. Starts with an existing conversation whose unread_count is 0.
  2. Delivers two different valid inbound message payloads concurrently for the same conversation.
  3. Forces both handlers to read the conversation before either update is applied.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions