Build notes · performance
The WhatsApp inbox that re-downloaded itself every visit
We were sure of it before we looked: the WhatsApp inbox was the busiest part of the product, so if anything felt slow, the database was where it would be. We had the queries in front of us. Two tables with no index for how they were actually being read. A handful of the same row fetched two, three, sometimes five times while processing one inbound message. All real, all worth fixing, all fixed the same afternoon. None of it explained the number we found once we stopped reasoning from the code and asked Cloudflare what actually happened to a real visitor.
The honest version of this note is not "we found the bottleneck." It is: we were wrong about where the bottleneck was, the evidence said so before our intuition caught up, and the fix that mattered had nothing to do with a single SQL query.
TL;DR
Real Cloudflare Web Analytics RUM data (60 real visits, 30 days) put the WhatsApp
dashboard's median page load at 5.0 seconds, P90 at 8.8
seconds, median time-to-first-byte at 502 milliseconds. A synthetic
request to an endpoint that touches no database at all cost the same 502
milliseconds. The database was not the story. The dashboard has no build step: over
40 separate JavaScript files, and every one of them, including files that already carried a
content-hash cache-busting parameter, was served Cache-Control: no-cache in
production. A browser paid a full network round trip to re-ask "did this change?" on every
single file, on every single visit, forever, for content that provably had not changed. The
fix sets Cache-Control: public, max-age=31536000, immutable on any versioned
request and leaves everything else untouched. Verified live in production after deploy, not
just in a test. What is not yet verified: the real-world number after the
fix, because that needs days of real traffic to measure honestly. Section below on exactly
that.
Where we looked first
The instinct was reasonable. The WhatsApp team inbox is, by a wide margin, the most-used
surface in the product: pulling real per-path request counts from Cloudflare's GraphQL
Analytics API confirmed /api/inbox/conversations and /api/inbox/lines
as the two most-hit paths on the entire domain, ahead of every marketing page combined. High
traffic and a slow feeling are an easy pair to connect, and the database is the easy place to
point first, because a slow query is a story everyone already knows how to tell.
So that is where we started. whatsapp_configs, the table that resolves which
tenant owns an inbound WhatsApp number, was being looked up by phone_number_id
and business_account_id on every single inbound message and status webhook, and
every index on that table led with a different column. Full table scan, on the first database
read of the hottest path in the system. A second table, the one backing a reachability check
run every 15 minutes from four separate background sweeps, had no supporting index at all.
Both got one, and both went from a raw table SCAN to an indexed
SEARCH, one of them landing as a covering index that never touches the table row
at all. Real problems, cleanly fixed, verified with EXPLAIN QUERY PLAN against
the actual database rather than a guess.
Alongside the indices: a WhatsApp message queue that re-fetched the same campaign configuration once per message instead of once per batch, and a message-processing path that queried the same creator's settings row up to five times while handling one inbound WhatsApp message. Both memoized, both scoped to a single request so there was nothing left to go stale, both proven safe by 480 existing tests passing unmodified afterward.
What the database actually cost
That is the test that mattered, and it is the one we should have run before touching a single query. A direct request to a genuinely static, zero-database endpoint returned in roughly 480 milliseconds, twice, consistently. A request to the real WhatsApp conversation list, the one we had just spent an afternoon indexing, cost about the same. Not close. The same. Whatever that 480 milliseconds was paying for, it was not SQL.
It got more convincing once we found the same number already sitting in the code. A comment left by an earlier investigation into the inbox's WebSocket reconnection logic said, almost verbatim: every API call here costs about half a second of pure network latency, regardless of how cheap the underlying query is, measured by comparing a no-database endpoint against the real conversation list. Two independent measurements, taken weeks apart by two different investigations for two different reasons, landed on the same number. That is not a coincidence you get to ignore.
Asking Cloudflare instead of guessing
Klaros is self-deployed, which means the WhatsApp Business platform your team uses runs in a Cloudflare account you own. That is normally a sentence about data residency and control. It is also, and this is the part worth actually using, a sentence about being able to ask Cloudflare directly what is happening to your own traffic, with the same credentials the deploy scripts already have, rather than staring at code and guessing.
Two calls to Cloudflare's GraphQL Analytics API, authenticated with the account's own API token, answered two different questions with real data instead of intuition. The first, account-scoped Worker invocation metrics, gave a rough server-side split of wall time versus CPU time. The second, and the one that actually settled it, was Cloudflare Web Analytics RUM: real browser measurements from real visitor sessions, not a synthetic check run from a development machine. Over the previous 30 days, 60 real visits to the dashboard: median page load 5.0 seconds, 90th percentile 8.8 seconds, median time to first byte 502 milliseconds, 90th percentile 2.2 seconds. A third of those real visits scored "needs improvement" or "poor" on Core Web Vitals' time-to-first-byte threshold.
A quirk worth naming rather than hiding: Cloudflare's own documentation for this exact RUM dataset says it has no API, dashboard access only. Live schema introspection against the account said otherwise, the fields existed and returned real data. Reference material can be stale in ways that cost you a genuine capability if you take it at face value. We queried the live schema instead of trusting the page.
The tools we did not reach for, and why
Two Cloudflare products look, on paper, like exactly what this problem calls for: Smart Placement, which moves a Worker's compute closer to a backend it calls repeatedly, and D1 read replicas, which serve reads from a location nearer the request. Both were checked against Cloudflare's current documentation before being ruled out, not assumed away.
Smart Placement optimises for distance to a backend the Worker calls a lot. This Worker also serves the entire dashboard's HTML, JavaScript and CSS directly, from the same deployment. Per Cloudflare's own guidance, enabling Smart Placement on a Worker shaped like that routes everything, static files included, to wherever the backend is, and can make a page load slower rather than faster, unless the static-serving half is split into its own Worker first. That is a real architecture change, not a flag, and the RUM evidence above already said the backend was not the slow part.
D1 read replicas solve read latency by serving a query closer to the request. The zero-database synthetic test had already shown that query latency was not where the 502 milliseconds went. Turning on a database feature to fix a problem the database was not causing would have been effort spent on the wrong layer, however good the feature is in the case where it is actually the database.
Forty files, one header
The WhatsApp dashboard has no build step. No bundler, no code-splitting: over 40 separate JavaScript files, loaded as native ES modules, one of them importing roughly 34 others directly and none of it lazily. Every one of those files gets its own HTTP request on every cold page load. That alone is a real cost. What made it a repeat cost, on the hundredth visit as much as the first, was the response header.
curl -I against the actual production URL a browser requests, complete with its
real cache-busting query parameter, returned Cache-Control: no-cache. Not "cache
for a few minutes." Not "cache and revalidate occasionally." no-cache, which tells
a browser to ask again, every time, before trusting anything it already has. The same file,
requested with and without its version parameter, returned an identical header and an
identical ETag either way: whether the URL claimed to be a specific, permanent version of the
file made no difference at all to how it was allowed to be cached.
The fix has two parts, and the harder one is not the header. First, the tool that stamps a
content hash onto each local file reference only ever refreshed a hash that was already there.
A file imported without one, and roughly half of them were, silently never got versioned at
all: not dashboard.js's own top-level imports, not the shared helper module that
20-odd feature modules all pull in. Extending that tool to catch every local reference, not
only the ones a person remembered to hand-version, closes that gap for good, and it runs
automatically on every future deploy from here on.
Second, in the Cloudflare Worker itself: any request to a JavaScript or CSS path that carries
its version parameter now gets Cache-Control: public, max-age=31536000,
immutable. Anything without one, an old link, a stray reference, anything this
deployment does not control, is served exactly as before, untouched. The distinction that
makes this safe rather than reckless: because the version string is a hash of the file's own
content, the URL itself is the freshness signal. The next code change produces a new hash, a
genuinely new URL, and the browser fetches that fresh, automatically, with nothing to
invalidate on the old one because nothing still asks for it.
What almost went wrong while fixing it
Two things went wrong before either shipped, caught by checking rather than trusting the first result.
The first pass at "version every local file reference" matched any quoted string that looked
like a local JavaScript or CSS path. It also matched
navigator.serviceWorker.register('/sw.js'), a runtime instruction to the browser,
not a reference to a file being loaded. Appending a version hash to that string would have
changed how the app's own service worker registers itself, a completely different system with
its own update semantics, as an accidental side effect of a caching fix. Narrowed to the three
contexts that are actually asset references and nothing else.
The second was stranger. One JavaScript file's own documentation comment shows, as a worked
usage example, the exact <script src> tag someone would paste to embed it.
That is a reference to the file, from inside the file, to itself. Hashing the file's content to
decide what version number that self-reference should carry changes the file's content, which
changes its hash, which needs a new version number, forever. It does not error. It just never
settles. Caught only by running the version-check twice in a row after applying it once and
noticing the second run was not clean, rather than assuming one successful run was proof of
anything.
What we can prove today, and what we can't yet
Everything above the header fix is proven the way this codebase insists on: an
EXPLAIN QUERY PLAN before and after for the indices, 480 existing tests passing
unmodified for the memoization, and for the caching fix, the actual header verified live
against production after deploy, not assumed from the code. A versioned request to
dashboard.js returns immutable. An unversioned request returns
exactly what it returned before. A request for a version that does not exist 404s cleanly. An
unrelated API route is untouched. All four checked directly against
www.tryklaros.com, not a staging copy.
What is not yet proven, and will not be claimed as proven here, is the real number after the fix. The 5.0 second median and 502 millisecond time-to-first-byte above came from 60 real visits accumulated over 30 days. A second, honest measurement needs a comparable number of real visits after this deployed, and that takes real time passing with real people using the product, not a query run five minutes after shipping. Publishing a percentage improvement before that number exists would be exactly the kind of claim these notes exist to avoid making. When the second measurement exists, it gets appended to this note, with the same willingness to report a disappointing number that this whole series has tried to earn.
What is still genuinely unsolved: the caching fix makes a repeat visit nearly free for every file that has not changed, but it does nothing for the very first visit, when nothing is cached yet and all 40-odd files still have to be fetched once. The deeper fix for that is a build step that bundles and code-splits the dashboard, which this product deliberately does not have today. If we build that, we will write that note too.
Questions people actually ask
Why did fixing the database queries not fix the felt slowness?
Because the database was never the dominant cost here. A synthetic request that touches no database at all cost the same 502 milliseconds as the real WhatsApp conversation list. The two missing indices and the redundant re-fetches were real defects and worth fixing on their own terms, but no amount of query tuning was going to move a number a zero-query endpoint could not move either.
What actually found the real cause?
Cloudflare's own GraphQL Analytics API and Web Analytics RUM, queried directly against the account's own production data with the same API token the deploy scripts already use. RUM gave the real number from real visitor sessions: 5.0 second median load, 502 millisecond median time to first byte, over 30 days of actual traffic. Neither number was guessed.
Why not just turn on Smart Placement or D1 read replicas?
Both were checked against Cloudflare's current documentation first. Smart Placement is flagged by Cloudflare's own guidance as risky for a Worker that also serves static assets, exactly this Worker's shape, without splitting it into two Workers first. D1 read replicas fix read latency, and the evidence had already shown the read itself was not where the time went. Reaching for either would have solved a problem the data did not describe.
What was actually wrong with the caching header?
Every dashboard JavaScript and CSS file, including ones already carrying a content-hash version parameter meant to enable long-term caching, was served Cache-Control: no-cache in production. Confirmed directly: the same file, requested with and without its version string, returned an identical header and identical ETag either way.
Does this need to be redone by hand on every deploy?
No. The tool that stamps a content hash onto every local file reference now covers all of them automatically, and it already runs as the first step of the existing deploy command. A new file added without a version string gets one the next time anyone deploys, with nothing to remember.
Has this actually been proven to make the dashboard faster yet?
The mechanism is proven live in production: a versioned request returns the immutable header, an unversioned one is untouched, exactly as designed. The real-world number after the fix is not yet proven, because that needs days of real traffic to accumulate for an honest second RUM measurement. When it exists, it gets appended here rather than assumed.
If you run Klaros, this is not a story you have to take our word for. It is your Cloudflare
account. Your API token is already sitting in .dev.vars. The same two queries
that found this for us will tell you what your own dashboard actually costs your own visitors,
today, not in a sales deck.
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
Not a form. 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 17 August 2026. We append when the facts change, including the real post-fix RUM number once enough traffic exists to measure it honestly. Related: the inbox polling rebuild, the loading skeleton regression, what self-deployed actually means, all build notes.
