When a patient searches for a therapist on Headway, they are not really searching a list of people — they are searching a list of open appointment times that match their insurance, their state, and the kind of care they need. That distinction is the whole reason search was slow, and fixing it is the whole reason it is now fast.
A year ago, a typical search took just under two seconds at the 95th percentile. For a product where the next step is booking an intake call, two seconds is enough hesitation to lose someone. We set a target of under 500 ms and ended up at roughly 540 ms p95 — a 70% reduction. Here is what actually moved the number.
Where the time was going
Our first instinct was that the database was the problem. It was — but not in the way we assumed. Profiling showed the query itself was fast; the cost was in computing availability on read. Every search joined providers against their calendars, expanded recurring rules into concrete slots, and filtered by payer — for every provider in the result set, on every request.1



Precomputing availability
Instead of expanding calendars at read time, we materialize the next 30 days of open slots per provider whenever their availability changes, and write the result straight into the search index. A booking, a cancellation, or a rule change enqueues a single recompute for that one provider.
// Recompute one provider's open slots on any availability change
const slots = await availability.materialize({
providerId,
window: { days: 30 },
});
await searchIndex.upsert(providerId, { slots });
Reads became a lookup instead of a computation. The index already knows who is open, when, and for which payers — so a search is now a filter over precomputed rows.
Search went from something patients endured to something that felt instant — and instant search means more booked first appointments.
Read replicas and a thinner index
Precomputation got us most of the way. The rest came from three smaller changes:
- Routing all search reads to dedicated read replicas, so booking traffic never contends with browsing traffic.
- Trimming the index to only the fields search actually filters and ranks on — specialty, modality, state, payer, and the slot windows.
- Caching the payer eligibility lookup, which rarely changes within a session.
We track search latency at p95, not average. Averages hide the slow searches, and the slow searches are exactly the ones where a patient gives up.
What changed for patients
Faster search is not the goal in itself. The goal is that someone who has worked up the nerve to look for a therapist finds one and books before the moment passes. Since the rollout, completed searches are up and the drop-off between search and first booking has narrowed measurably — which, for a mental-health platform, is the metric that matters.
What we measured
Completed searches, search-to-booking drop-off, and p95 latency — tracked per market so a regression in one state cannot hide behind the national average.
There is more to do — availability windows beyond 30 days, smarter ranking for first-time patients — but the architecture is finally one we can build on. If working on problems like this sounds good, we are hiring.
Footnotes
-
We track the 95th-percentile latency rather than the average, because the slow tail is exactly where patients abandon a search. ↩
