Every few weeks someone tells me a page feels slow, and every single time the first suspect is the server. Mine was innocent — and it took me two wrong fixes to prove it.
This is the story of a Chatwoot inbox that took several seconds to open a conversation, the numbers that cleared the server, and the one line of frontend code that was doing the same expensive work twice.
Start with the boring measurements
Before touching anything, I pulled the numbers. Six hours of read-only telemetry across the whole stack:
- Web instance CPU: 5.4% average, 13.2% peak. CPU credits full.
- Worker: 4.7% average, credits untouched.
- Database: 6% CPU, read and write latency around 1 ms, 15–20 connections, 246 MB of data.
- Cache layer: 3% CPU, zero evictions.
- Application responses at the load balancer: 172–413 ms. Not one request above half a second.
So the server was fine. The network was fine. And yet the page felt like it was thinking about it for five seconds before doing anything at all.
That gap — healthy backend, unhappy human — is the signature of work happening in the wrong place.
Then measure from a browser sitting next to the server
Measuring from my own laptop proves nothing, because my line is part of the experiment. So I attached a debugging session to a Chrome instance running inside the same data centre, opened the exact same conversation, and recorded the timeline twice: once cold, once warm.
DOMContentLoaded: 2.4–3.2 seconds- JavaScript and CSS decoded on each load: about 23.7 MB
- Long tasks after clicking a conversation: ~8.4 seconds, worst single task 5 seconds
- Outgoing requests in the first seven seconds: none
That last line is the one that reframed everything. The page was not waiting for the server. It was frozen before it ever asked the server for anything.
The scaling work that helped — and the part that proved me wrong
I still did the sensible infrastructure work first, because a slow app on an undersized box is a real problem even when it is not this problem:
- Moved the web instance from a small burstable type to a medium one (same two vCPUs, double the memory).
- Set
WEB_CONCURRENCY=2so Puma actually uses the second core: 2 workers × 5 threads. - Changed deploys from all-at-once to rolling, so future releases stop taking the whole environment down.
Eight parallel conversation-list requests went from 2.82–4.52 s to 1.17–2.24 s. Real, measurable, worth keeping.
Then I tested the obvious next steps, and both failed to move the number:
- Three Puma workers instead of two: within variance, no gain.
- Threads per worker at 5, 10, then 15: the slow path stayed slow, and the last run got worse.
More threads do not fix a page that is stuck on the main thread. That experiment cost me an afternoon and saved me a month of wrong assumptions.
My first fix was in the wrong place
The first hypothesis was reasonable: an inbox getter was walking a large WhatsApp message-template payload when it only needed the inbox. I wrote a fix, measured it in production, and got this:
- Deep walk: 5,548 ms → 4,669 ms
- Long tasks after click: 9.5 s → 8.4 s
- Requests still not leaving within seven seconds.
Better on paper, useless in practice. The deep walk was still happening — just somewhere else on the same path. I closed that pull request rather than pretend a micro-optimisation had solved anything.
Profile the main thread instead of guessing
So I stopped reading code and started recording it. Three cheap instruments:
- A hook on
Array.prototype.mapthat only logs arrays longer than 500 entries. - A CPU profile taken around a single click.
- A
PerformanceObserveronlongtask, so I could see the freeze as a duration rather than a feeling.
The stack came back with the same shape twice:
Array.map (2,000+ entries) ← message templates
← Vue reactive proxy
← map-obj deep walk
← map-obj deep walk (yes, twice)
The culprit was a composable that converts inbox records to camelCase. It read from a getter that already camelCases the entire record, then ran useCamelCase(..., { deep: true }) over the result. Two full deep walks of the same object, on every conversation change, on the main thread — because the header, the message meta and the bubbles all depend on that computed value.
One line, done twice
The fix removes the second walk and keeps the behaviour. Nothing clever, no new dependency, no cache to invalidate later. Just: do not do the same expensive thing twice.
Measured in production after deploying the built assets:
- Long tasks after clicking a conversation: 8,368 ms → 1,984 ms
- Worst single task: 5,007 ms → 447 ms
- The deep walk: gone from the profile
- Map calls over 500 entries: 25 → 0
That is the difference between “this app is slow” and “this app is fine”.
Shipping it before upstream says yes
I did not want to wait for a review cycle to give my team a usable inbox, so the fix currently runs as a frontend asset overlay on top of the release image. Backend and database stay untouched — it is only the built JavaScript that differs.
That comes with an honest operational risk, and I wrote it down in the internal runbook: any future deploy that uses a different version label will silently replace those assets and quietly drop the fix. A silent regression is worse than a loud one, so the runbook names the exact label and the condition for removing it.
Upstream
The proper fix is a pull request against Chatwoot itself: perf: stop deep-camelCasing inbox records twice on every conversation change, with a linked issue describing the symptom, the root cause and how to reproduce it.
I also compared it against pull requests that were already merged in that repository and rewrote mine to match their conventions — issue reference, template, test steps, checklist. My first version followed my own structure, which is a good way to be ignored.
Right now the pull request is open, all checks pass, and nobody has reviewed it yet. I also left a comment on an older issue with the same class of symptom — freezing when opening a conversation — explaining the cause we found and the profiling method anyone can repeat, while being clear that the trigger there may be different.
So the current state is this: production is fast, the fix is documented, the upstream pull request is open, and I am waiting for it to be approved and merged. If it lands, everyone on Chatwoot gets the improvement and I get to delete the overlay.
If it does not, at least the profiler now has a permanent home in my toolbox — and the next person who says “the server feels slow” gets asked one question first: did you measure it in the browser?

Leave a Reply
You must be logged in to post a comment.