Last winter we were asked to look at a booking engine that had quietly stopped converting. The team had spent eight months on a redesign — new typography, new photography, a calmer hero, a faster CDN. Lighthouse said it was fine. The marketing director was confused. The CFO was less confused than upset. Could we take a look?
The symptom
On the staging environment, everything felt fine. On production, with real availability data, the page took roughly nine seconds to become useful. The HTML was on screen in 600 milliseconds. The room cards weren't.
The graphs told a story we'd seen before: a near-flat CPU profile, a relaxed memory line, and a network waterfall that looked like a small staircase descending into the floor. The page wasn't slow because the server was slow. It was slow because the page was making one request, waiting for it, then deciding what to ask for next.
What was actually slow
The booking flow was orchestrating nine separate API calls — rates, availability, taxes, supplements, restrictions, room features, hero photography, breadcrumb data, and a final "is this still valid?" handshake — and it was doing them in series, because the rates response told it which room IDs to fetch, and the rates response was 2.4 seconds deep.
None of those calls were individually pathological. Each took between 180ms and 900ms. The cumulative latency was nine seconds because of how they were chained. As one of our engineers put it on the call: "this isn't a performance problem, it's a graph problem."
The page wasn't slow because the server was slow. It was slow because the page was making one request, waiting for it, then deciding what to ask for next.
The fix, in four lines
The fix wasn't to make any single call faster. It was to stop making the page wait for them. We did three things:
- Pushed the dependent decisions up into a single GraphQL resolver — one round-trip from the client, one query plan on the server.
- Parallelised what the resolver itself fetched (rates and restrictions do not need to be sequential).
- Pre-warmed the photography URLs in the HTML response so the browser could start downloading them before the JS bundle had finished parsing.
Here's the relevant change to the resolver, edited down to the part that matters:
// Before — sequential, blocks on rates
const rates = await getRates(propertyId, dates);
const rooms = await getRooms(rates.roomIds);
const tax = await getTax(propertyId, rates.total);
// After — parallel, single round-trip
const [rates, rooms, tax] = await Promise.all([
getRates(propertyId, dates),
getRooms(propertyId, dates), // doesn't need rates.roomIds
getTax(propertyId, dates),
]);
Four lines, depending on how you count them. The page now becomes useful in about 1.4 seconds on a cold cache. Conversion came back, and the redesign — which was, for the record, very good — finally got the credit it deserved.
How to measure your own
If you suspect the same shape of problem, here are the three things we'd look at first:
- Open your network waterfall on a real device, on a real network. Not your laptop on the office Wi-Fi. The staircase pattern is unmistakable.
- Count the requests between "HTML arrived" and "first useful paint". If it's more than three, you almost certainly have a graph problem.
- Look at server-side timing headers. If your server timing adds up to a fraction of the client-perceived time, the latency is travelling, not computing.
A note on tooling
You don't need exotic observability for this. Chrome DevTools' waterfall, a couple of console.time() calls, and a willingness to refresh the page on your phone will get you 80% of the way there. The remaining 20% is where a tool like SpeedCurve or Sentry's performance tab starts paying for itself.
Takeaway
Most performance problems in modern web apps aren't about how fast the code runs. They're about when it runs, and what it's waiting on. The graphs you're looking at are mostly travel, not work.
We've now seen this same shape of problem in four different booking engines and two e-commerce stacks. The fix is almost always the same: collapse the dependency graph, parallelise the rest, and pre-warm what the browser will obviously ask for next. None of it is novel. All of it is forgettable. That's why it keeps happening.