Build notes · performance
The WhatsApp inbox skeleton that made things worse
A shop finishes Meta's Embedded Signup. Their first WhatsApp Business number is live. A customer messages them within the hour, and the conversation appears in the Klaros inbox. Then another. Then a third. The shop owner is looking at their first real WhatsApp threads, deciding whether this software is worth paying for, and the conversation list is flashing with grey shimmer blocks every five seconds, for no reason they did anything to cause.
We added skeleton loaders to make the inbox feel faster on filter switches. They did. Then they started firing on the background poll and made the whole thing worse, and they did it specifically to trial users with small inboxes, which is to say: the exact people whose first impression determines whether they stay.
TL;DR
Skeleton loaders improved perceived speed on filter switches and search, but loadConversations() took no arguments, so the skeleton fired on every call, including the background 5-second poll that checks for new WhatsApp messages. On inboxes with fewer conversations than the page size (trial accounts, new deployments), the list flashed with shimmer blocks every 5 seconds. The fix: change the signature to loadConversations(opts). Only 9 user-initiated call sites pass { skeleton: true }. The ~20 incremental-update sites and the polling path do not.
Thirty callers, one function, three intentions
loadConversations() is the function that fetches the conversation list from the
API and renders it into the DOM. It is called from roughly 30 places across the inbox code.
Every one of those call sites called the same zero-argument function and got the same
behaviour. But the 30 callers have three completely different reasons for calling.
The first category is user-initiated actions where the agent expects to wait. Switching a filter to show only one WhatsApp number's conversations. Changing the category tab. Typing a search query. Loading the inbox for the first time. There are 9 of these. A skeleton is exactly right: the agent just did something and the skeleton confirms the system is responding.
The second category is incremental updates after a known WhatsApp action. The agent resolved a conversation, which closes the 24-hour service window. They assigned a thread to another agent. They sent a template, which starts Meta's per-message billing clock. They marked something as unread. There are roughly 20 of these. The list needs to refresh, but showing a skeleton is actively misleading. The agent knows what just happened. A skeleton implies "loading from scratch," which makes a one-second refresh feel like the system lost its place.
The third category is the background poll. Every five seconds, the inbox checks whether Meta has delivered any new webhook messages since the last check. There is exactly one of these. A skeleton here is a regression. The agent did nothing. The list should update silently if a new WhatsApp message has arrived, or hold still if it has not.
Before this fix, all three categories got skeletons. The function did not know who called it or why.
Why skeletons are worth the trouble
The instinct after a regression like this is to remove skeletons entirely. That would be wrong. The problem they solve is real, and it is specific to WhatsApp inboxes where the data behind each filter is a different API query.
Without skeletons, a filter switch looks like this: the agent taps "Resolved," and for 300-500ms the list shows the old "Open" conversations. Every name, every WhatsApp message preview, every unread badge belongs to a different filter. Then the list snaps to the correct data. On a slow connection, the stale data sits there for over a second. The agent can tap into a conversation that is about to disappear from the list, opening a thread whose service window may already be closed.
With skeletons, the moment the agent taps "Resolved," the list is replaced with grey blocks. The old data is gone. The agent cannot interact with stale content. When the new data arrives, it replaces the blocks. The transition is honest: it says "I am fetching" instead of silently showing you the wrong conversations.
The skeleton is not a decoration. It is a state declaration. It says: the content you are about to see is not the content you are looking at. That declaration is valuable when the agent initiated the change. It is nonsense when the system is doing a background check for new WhatsApp messages that the agent did not ask for.
Where it went wrong: the trial inbox
The background refresh function, refreshConversations(), runs on a 5-second
timer. It checks whether the loaded conversation count is at or below the page size. If it
is, it calls loadConversations() to do a full reload. If the list is larger, it
takes a different branch that merges updates incrementally.
This means the skeleton flicker only appeared when the inbox had fewer conversations than the page size. A mature inbox with hundreds of WhatsApp threads took the incremental branch and never showed skeletons on the poll. But a shop that just finished Embedded Signup and connected their first WhatsApp Business number has 5 or 10 conversations. They hit the full-reload branch every 5 seconds. They get shimmer blocks every 5 seconds.
The people most likely to see the bug were the people we most needed to impress. A trial user, still inside their evaluation window, watching their first real WhatsApp threads, sees a conversation list that flashes with loading indicators constantly while they are trying to decide whether Klaros is the right tool for their business. We tested on an inbox with 200 conversations. The bug was invisible. We shipped.
The fix is one parameter
The function signature changed from loadConversations() to
loadConversations(opts). The opts object is optional. The only
property it checks is skeleton.
Nine call sites now pass { skeleton: true }:
- Search input debounce
- WhatsApp number filter switch
- Assignment filter chip
- Category tab switch
- Queue toggle
- Status filter
- Entity filter
- Initial page load
- Deep-link page load
The roughly 20 incremental-update sites pass no options. Resolving a conversation (closing
the service window), assigning it (routing it to another agent), sending a template (starting
the billing clock), marking a thread as unread: all of these refresh the list silently because
the agent already knows what happened. The background poll passes no options either. In all
cases, opts.skeleton is falsy and the skeleton does not fire.
The change is small. One parameter, one property check, nine call-site edits. The reason it matters is not the code. It is the principle underneath: a function that does not know why it was called cannot make the right UX decision. Showing a loading indicator when the agent resolves a WhatsApp thread is a different question from showing one when the background poll finds no new webhooks. The skeleton is a UX decision, not a technical one. It needs context that only the caller has.
What is still not ideal
The search debounce was 250ms and is now 450ms. Every pause in typing still fires a new API call. If an agent types a contact name in the search box, that is potentially three requests, each querying the WhatsApp conversation list for a partial match. Only the last one matters. The earlier responses are discarded when the next one arrives, but the server still did the work.
And, as with the polling rebuild, the deeper waste is the poll itself. Meta delivers webhooks to the server, but the server cannot push them to the browser. The skeleton fix and the hash fix both optimise what happens after the poll response arrives. Neither eliminates the request. The Durable Object WebSocket approach that would let the Worker push new WhatsApp messages directly to the agent's browser is a larger architectural change than either of these fixes.
So for now: skeletons fire only when a human did something. The poll is silent. The list holds still. And the first thing a trial user sees after Embedded Signup is a conversation list that does not flash.
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 WhatsAppAsk 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.
+91 97893 77634 · you message first, so nothing reaches you without your say-so.
Written 10 August 2026. We append when the facts change. Related: polling rebuild fix, all build notes.
