Build notes · performance

The WhatsApp inbox that rebuilt itself every five seconds

10 August 2026 · 17,280 DOM rebuilds per day, zero of them necessary · Founder, Klaros

A jewellery customer sends a photo of a chain at 4:07pm. Her agent sees the message appear in the conversation list, opens the thread, starts typing a price. Behind their reply, invisible to them, the entire conversation list is silently torn apart and rebuilt from scratch. Five seconds later it happens again. And again. Fifty rows destroyed, fifty recreated, layout recalculated, 17,280 times a day, on a list that has not changed since the morning's first WhatsApp.

We did not find this because somebody reported it. On a fast laptop the rebuilds complete inside a single frame and no one can tell. On the mid-range Androids our agents actually carry, the rebuild eats most of the frame budget while they are composing a reply inside a 24-hour service window. The kind of thing a user describes as "I don't know, it just feels slow sometimes" and nobody can reproduce on a developer's machine.

TL;DR

When a WhatsApp message arrives, Meta's webhook delivers it to our Cloudflare Worker. The Worker writes it to D1 and exits. The browser never hears about it directly, so the inbox polls every 5 seconds. Until this fix, every poll destroyed and recreated the entire conversation list DOM, even when nothing had changed. A four-field hash now compares the list shape before rendering. If the hash matches, the function skips the rebuild. Full DOM rebuilds dropped from ~5,760 per 8-hour shift to a few dozen.

Inbound: webhook delivery Customer sends WhatsApp message Meta webhook POST Cloudflare Worker parse + write to D1 D1 (SQLite) Worker exits No push path The gap: server cannot push to browser Outbound: browser polls every 5 seconds Agent's browser GET /conversations Worker queries D1 returns JSON list D1 same data JSON response Client-side: hash comparison lastListKey matches? length:first:last:unread Yes: skip No DOM work No: rebuild 50 nodes, full layout ~5,700 polls/day ~few dozen/day
The gap between webhook delivery and browser awareness. Meta pushes to the Worker; the Worker cannot push to the browser.

Why a WhatsApp inbox has to poll

When a customer sends a WhatsApp message, Meta delivers it as a webhook POST to our server. The Cloudflare Worker that receives it parses the payload, writes the message to D1, updates the conversation's last-message timestamp and unread count, and exits. The Worker has no way to reach the agent's open browser tab. There is no long-lived process to hold a WebSocket open or push server-sent events. The runtime is stateless by design, which is why it scales well and why it cannot push.

So the inbox polls. Every five seconds, the browser asks the conversations endpoint: what changed? The server queries D1, serializes the list, and returns it. The browser parses the JSON and renders the conversation rows. This is the gap between how WhatsApp messages arrive (pushed to the server by Meta) and how the agent sees them (pulled from the server by the browser). Within that gap, the only question is how cheaply the client can decide that nothing has changed since the last pull.

Five seconds is a trade-off. Shorter intervals waste more bandwidth on identical responses. Longer intervals mean a customer's message sits invisible while the service-window clock is running. At five seconds, the latency is acceptable for a team inbox. The problem was never the interval. It was what happened after each response arrived.

What a rebuild costs while someone is replying

A conversation row is not a simple element. It contains an avatar, a contact name, the last WhatsApp message preview truncated to one line, a timestamp, an unread badge, assignment indicators, and status icons showing whether the thread is open, resolved, or waiting on an approved template. Each row is a small tree of nested elements with flex layout, text truncation, and conditional classes.

Destroying 50 of these and creating 50 new ones forces the browser through a specific sequence: remove every child node, garbage-collect the old nodes, parse the new HTML, insert it into the DOM, and run layout. Layout is the expensive part. The browser has to measure every piece of text, compute every flex container, resolve every avatar dimension, and determine the final position of every element. This is not a repaint of pixels. It is geometry from scratch.

On a Snapdragon 665 (a common mid-range chip in the phones our early users carry), a full layout pass on 50 conversation rows takes between 8 and 14 milliseconds. A frame budget at 60fps is 16.6ms. The rebuild alone consumes most of the frame, leaving almost nothing for input handling or scrolling. An agent replying inside a service window is composing on a keyboard that subtly lags every five seconds. They cannot point to it. They cannot name it. They just feel that the inbox is slower than it should be for software they are evaluating on a trial.

The hash that makes most polls free

The thread view, the right-hand panel that shows the actual WhatsApp messages, already had this solved. A variable called lastThreadKey stored a hash of the currently rendered thread. When new data arrived, the code compared the hash before touching the DOM. If it matched, it skipped the render. The conversation list, the panel that shows every active WhatsApp thread at a glance, never got the same treatment.

The fix is a variable called lastListKey. After the conversation list HTML is built (but before it is inserted into the DOM), the code constructs a key from four values:

htmlLength + ':' + firstPhone + ':' + lastPhone + ':' + totalUnread

If this key matches the stored key from the previous render, the function updates only the active-conversation highlight (a single classList.toggle on two elements at most) and returns. The DOM stays untouched. No destruction, no creation, no layout.

The hash is deliberately imprecise. It does not hash every field of every conversation. A tighter hash does more work per poll to arrive at the decision "skip," which moves the cost rather than removing it. The current hash catches the changes that matter visually: a new WhatsApp message bumping a thread to the top, a conversation disappearing after resolution, the sort order shifting, and unread counts changing. It misses things that are cheap to miss, like a preview-text update mid-conversation. If a customer sends a second message in a thread the agent is not currently viewing, the preview text in the list might lag by one poll cycle. Nobody has ever noticed, because nobody stares at preview text while it updates.

The arithmetic: at 5-second intervals, a browser tab open for 8 hours fires 5,760 polls. Before the hash, every one of those was a full DOM rebuild. After, the rebuilds happen only when a real WhatsApp event changes the data: an inbound message, a resolution, an assignment, a template send. On a typical workday that is a few dozen times. The remaining 5,700-odd polls cost a string concatenation and a strict equality check.

The fastest DOM operation is the one you skip entirely. Every optimisation that makes a rebuild cheaper is solving the wrong problem if the rebuild did not need to happen.

What is still wasteful, and why we can see it

The API call itself. Every five seconds, the browser sends a request. The Worker queries D1. D1 runs the SQL against its SQLite store. The Worker serializes the result. The response crosses the network. The browser parses the JSON. All of this happens even when no WhatsApp message has arrived since the last poll.

A WebSocket model would eliminate this: the server would push only when Meta delivers a webhook, and the client would sit idle between pushes. But Cloudflare Workers have no persistent process to hold a connection open. Durable Objects can hold WebSockets, but routing every inbox session through a Durable Object changes the architecture and the cost model.

Klaros is self-deployed. You run the Worker on your own Cloudflare account. Your D1 database is yours. That means you can do something a managed WhatsApp platform never exposes: query your own database, count your own poll responses, and measure exactly how much redundant work the server is doing on your behalf. We ran that query. On a quiet afternoon with 50 conversations, 98.7% of poll responses were byte-for-byte identical to the one before. The hash turns those into no-ops on the browser side. The server-side waste remains, and it is visible, measurable, and ours to fix next.

If we move to Durable Object WebSockets for the inbox, we will write that note too. For now this is where we are: 5,760 API calls a day, a few dozen DOM rebuilds, and a conversation list that finally holds still while the agent types a reply.

Get the next one on WhatsApp

Message the line and type NOTES. The latest one comes straight back, in the same thread, from the number that sends everything else. Reply STOP whenever you like and it stops.

Send NOTES on WhatsApp

Ask our WhatsApp number what it costs

The most honest demo we have is the product itself. Message the line and type pricing: you will get our live catalog as a WhatsApp list, and a payment link on whatever you tap. No signup, no call, no PDF.

Written 10 August 2026. We append when the facts change. Related: skeleton flicker regression, all build notes.