Build notes · performance

La Bandeja de Entrada que se Reconstruía Cada Cinco Segundos

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.

Questions people ask about this

Why does the Klaros inbox poll instead of using WebSockets?

When a customer sends a WhatsApp message, Meta delivers it as a webhook POST to the Klaros Cloudflare Worker. The Worker writes the message to D1 and exits. There is no long-lived process to hold a WebSocket open or push the new message to the agent's browser. Polling at a fixed interval is the architecture that fits the stateless Workers runtime, and the optimization work happens on the client side: deciding how cheaply the browser can determine that no new WhatsApp messages have arrived since the last poll.

How does the conversation list hash work?

The hash is a string built from four values: the total HTML length of the rendered list, the WhatsApp phone number of the first conversation, the phone number of the last conversation, and the total unread count across all conversations. If this string matches the one from the previous render, the function skips the DOM rebuild entirely and only updates the active-conversation highlight. The hash is deliberately coarse: it catches new WhatsApp messages bumping a thread to the top, conversations disappearing after resolution, and unread counts changing, which are the changes that matter visually.

What does a full DOM rebuild cost on a mobile browser?

Destroying and recreating 50 WhatsApp conversation-row nodes forces the browser to run layout recalculation on every row: measuring text, computing flex sizes, resolving avatar dimensions. On the mid-range Androids that many WhatsApp CRM agents carry, this takes long enough to drop a frame or two, which is imperceptible once but becomes a persistent micro-stutter when it happens every five seconds while the agent is composing a reply inside a service window.

Does the hash eliminate all wasted work?

No. The API call itself is unchanged. Every five seconds, the browser sends a request, the Worker queries D1, serializes the conversation list, and the browser parses it. The hash only eliminates the DOM rebuild that follows. Removing the API call entirely would require server-pushed events when Meta delivers a new WhatsApp webhook, which the stateless Workers runtime does not support without Durable Objects. The hash turns a costly DOM operation into a cheap string comparison, but the network round-trip remains. Because Klaros is self-deployed, operators can query their own D1 and measure exactly how many poll responses are identical, something a managed WhatsApp platform would never expose.

Written 10 August 2026. We append when the facts change. Related: skeleton flicker regression, what self-deployed actually means, all build notes.