All posts
EngineeringSeptember 21, 20266 min read

Why our support inbox counted visits as conversations

By Jernej

Our chat widget created a conversation when a visitor joined the widget connection. The visitor did not have to send a message. They could leave the page, having asked for nothing, and leave a conversation row behind.

That made an unfiltered count of conversations include visits where nobody wrote to support. The inflated count did not change any support decision. We fixed it because the row was being created before there was anything to put in it.

Where the empty conversations came from#

The old join handler looked for a conversation linked to the visitor. If it did not find one, it called findOrCreateActive. That could create an open conversation, apply assignment rules and notify the dashboard about a new conversation, all before a message arrived.

It wasn't literally one new conversation for every page view. The code could reuse an active thread for the same visitor and channel. But a visitor without one could acquire a thread just by loading the widget. Counting those rows as support conversations mixed people who had written to us with people who had only visited.

In our shared inbox, a presence record says someone is on the site. A conversation record is where we store the exchange with them. The join handler created that record before the exchange started.

Filters already hid empty rows in the inbox query and the conversations analytics endpoint. Those filters were useful, but they didn't change what we were writing to the database. Any count that omitted the message filter could include the empty rows again.

Counting both groups over the same period#

We prepared a read-only report for our own inbox. It counts empty and non-empty website-chat conversations from one cohort, in one database statement. It does not export customer names, shop names, message text or individual conversation IDs.

The aggregate has not yet been supplied for this draft. We are leaving the fields below unfilled rather than treating a code comment or an older cleanup report as a measurement.

MeasurementOwn-inbox result
Creation period, start inclusive and end exclusive, UTC[TBD: awaiting real data]
Age cutoff, exclusive, UTC[TBD: awaiting real data]
Measurement time, UTC[TBD: awaiting real data]
Eligible website-chat conversation rows[TBD: awaiting real data]
Rows with no persisted messages[TBD: awaiting real data]
Rows with at least one persisted message[TBD: awaiting real data]
Empty share of eligible rows[TBD: awaiting real data]
Whether cleanup or retention had already removed rows[TBD: awaiting real data]

The selection is specific: CUSTOMER conversations in WEBSITE_CHAT channels, belonging to our authorized inbox, created within the chosen period and before the age cutoff. Both counts use those same conditions. A human running the report chooses a cutoff at least a full day before execution so newly created rows have had time to receive a message.

An empty row has no messages of either direction. A row with an outbound message, even an unsent draft, is non-empty even if it has no inbound message. This is a check for empty database rows, not a count of customers who asked for help.

Email stays out. Our email composer and the continue-via-email flow can legitimately create a thread before the first send. Treating every empty thread across every channel as this bug would overcount it.

Our existing cleanup report had another problem: it applied an age floor to its empty count but not to its total widget count. Dividing those counts does not give the empty share of a matched cohort. The new report builds the cohort once, then splits it by whether a message exists:

eligible rows = empty rows + non-empty rows
empty share = empty rows / eligible rows

If the cohort has no rows, the share is undefined. The report returns null, not zero per cent.

There is a limit to what this can recover. We inspect message state when the report runs, not when the creation period ended. A visitor may have returned and written since then. Cleanup may already have removed empty rows. The result describes the stored cohort at measurement time; it cannot reconstruct every silent visit or prove a before-and-after improvement.

What changed in the widget#

The join handler now looks up an existing active conversation without creating one. A new visitor receives a null conversation ID and empty history. Presence still has somewhere to go: Redis and the visitor-session records. Returning visitors can receive their existing thread and history.

Creation moved to the visitor-message path, through ensureWidgetConversation in src/services/widget/widget-conversation.service.ts. Both the socket message handler and the HTTP message fallback call this service. Fixing only the socket path would have left a different lifecycle when the widget used HTTP.

The service keeps the work that belongs with creation together: assignment and priority rules, customer context, the visitor-session link and the new-conversation notification. The join context waits on the socket until it can be written with the first conversation. The repository still finds or creates the active thread for that visitor and channel rather than deliberately opening a thread for every message.

That change does not remove historical empty rows, so we kept the message filters. It also does not make an empty row impossible: creating the conversation and persisting the message are separate operations. If the latter fails, the former can remain. A follow-up audit should investigate those rows, not assume every empty row proves that join-time creation returned.

How to check your own live chat conversation count#

Start in a test workspace with a visitor identity that has no existing thread. Record the stored conversation count, load the page with the widget, wait for it to connect, then leave without sending anything. Check the stored rows as well as the visible inbox. An inbox filter can hide a row that an export or a separate report still counts.

Repeat with a visitor who sends a message. Reconnect with that same identity and inspect whether the existing thread is reused. If your widget has an HTTP fallback, test that separately too. Record which action first creates the conversation, rather than assuming the dashboard label tells you.

For a database audit, restrict the export to one channel type and a fixed creation period. Apply the same age cutoff to the empty count and the total. Count the existence of messages, not message rows joined directly into the total: a conversation containing several messages must still count once. Keep legitimate unsent email threads out of a website-chat audit.

Then compare the definition with the number you use. A count of rows containing any message is different from a count of visitor-initiated requests, and both differ from unique people or resolved problems. If your tool exposes only a headline count, ask its vendor which event starts a conversation and whether empty threads are included.

Our code change removes creation from the join handler. The audit still needs to report what remains in storage, with its dates and exclusions attached.