The calendar took 13 seconds to load on the biggest fleet in our system. Every morning, at 8 AM, the dispatcher sat and waited. Then they clicked to the next month. Another 13 seconds. They did this five or six times a day.
Google’s web.dev guidance targets a Largest Contentful Paint of 2.5 seconds or less, measured at the 75th percentile. The classic human-factors basis goes back further: Robert B. Miller’s 1968 paper on “Response Time in Man-Computer Conversational Transactions” established that a wait longer than 2 seconds breaks the user’s concentration. We were 5x over the modern threshold and 6x over the human-factors threshold.
Here’s how we fixed it.
How We Profiled It
Server timing broke down like this:
- 9 seconds in the database
- 2 seconds in serialization
- 1.5 seconds on the wire
- 0.5 seconds in render
The database was the obvious target. But why?
The Bug
A single N+1 query. For every job on the month view, we fetched the associated workorders one row at a time. On a typical mid-sized fleet, the month view shows ~150 jobs. Each job averages ~2.5 workorders. That’s 375 individual queries to render one page.
On the largest fleet — 300+ jobs in a month — it was ~1,200 queries to load one calendar view. Every. Single. Time.
Fix #1: Eager-Load the Workorders
Instead of fetching workorders one-at-a-time per job, we loaded them all in a single batch query and joined them in memory.
Result: 13 seconds → 4 seconds. The database time dropped from 9 seconds to 2.5 seconds. The serialization cost went up slightly (more data in memory), but the net effect was a 3x improvement.
This was a one-line ORM change: .includes(:workorders) instead of letting the serializer call .workorders in a loop.
Fix #2: Materialize the “Is This Day Fully Booked?” Calculation
The month view shows a visual indicator for each day: is the day fully booked, partially booked, or available? That calculation was happening at read time — the server iterated over every job on every day, summed the truck-hours, compared them to the fleet capacity, and produced a boolean per day.
For a 30-day month with 300 jobs, that’s 300 comparisons per page load. Most of them return the same result day after day — the only time a day’s booked status changes is when a job is created, moved, or cancelled.
We moved the calculation to write time. When a job is created, moved, or cancelled, we recalculate the day’s booked status and store it. The month view reads the pre-calculated boolean instead of computing it on the fly.
Result: 4 seconds → 1.4 seconds. The database time dropped again, and the serialization cost dropped because the payload was smaller (no per-job truck-hour summation needed).
Fix #3: Prefetch the Next Month While the Dispatcher Is Still on This One
The dispatcher is looking at June. They’re going to click “next” to see July. We know this. So while they’re looking at June, the server is already rendering July and caching it.
When they click “next,” July loads from cache. To the dispatcher, it feels instant. The actual render time for July is still ~1 second, but the user never waits for it because it was already done.
Result: perceived load time from the user’s perspective → under 200ms for navigation.
What We Measured After
The largest fleet in the system now loads the month view in under 1 second. That’s measured on a real user’s session, on a real network, during morning peak.
For the typical fleet (50–100 jobs/month), the load time is under 400ms. The dispatcher clicks, the calendar appears, they keep working. No waiting. No frustration. No “I’ll come back to this later” — which is what happens when software makes you wait 13 seconds. You stop using it.
What This Is Worth
12 seconds saved per click × 5–6 clicks per day × every dispatcher × every location.
For a single dispatcher, that’s roughly 1 hour per month of recovered time. For a network with 5 dispatchers, that’s 5 hours a month. Not of idle waiting — of active dispatching that wasn’t happening because the screen was loading.
Slow software doesn’t just waste time. It changes behavior. People stop clicking. They stop checking. They work from memory instead of from the system. The data gets stale. The decisions get worse. Speed is not a nice-to-have — it’s a functional requirement.
If your dispatch board takes more than 2 seconds to load, that’s an answerable problem. Talk to us.
References:
- Google web.dev — Largest Contentful Paint — https://web.dev/articles/lcp
- Google Search Central — Core Web Vitals — https://developers.google.com/search/docs/appearance/core-web-vitals
- Google web.dev — Defining Core Web Vitals Thresholds — https://web.dev/articles/defining-core-web-vitals-thresholds