The Booking Availability Engine
How a public scheduling API went from twelve seconds to under one — and why the fix that mattered lived in the architecture, not the algorithm everyone suspected.
Patients book appointments through a public scheduling widget. Behind it, the system has to answer a deceptively simple question: what can this patient actually book, and when is the next opening? — sometimes up to ten months out, fast enough to feel interactive.
Computing one slot is combinatorial. For every date, the engine intersects provider × operatory × custom hours × existing appointments × timezone/DST × service duration — across four clinical verticals (dental, orthodontics, chiropractic, optometry), each with different rules, and across many EHRs. I designed this engine on our new NestJS platform in 2025. This is the story of what happened when it got slow.
The intimidating code was innocent.
The reputable library underneath it wasn't.
On the beta environment, the availability API was returning in roughly twelve seconds across all four verticals. For an interactive booking flow that's not slow — it's broken. And the cost compounded: the wider the date range a patient requested, the worse it got.
The instinct on the team was reasonable and — as it turned out — wrong: "the scheduling algorithm must be too heavy." Combinatorial work looks like the culprit. It rarely is the one you can prove.
I don't optimize from intuition. Before touching the algorithm, I instrumented the hot path with targeted [PERF] timers at three boundaries — every Couchbase query, per-date computation inside the worker, and batch totals — and ran a representative request.
The intimidating algorithm was innocent — its per-date math ran in milliseconds. The cost lived one layer down: the reputable scheduling library it delegated to, built on moment.js, materialized a 1,440-entry array per schedule per date and threw it away. At real request shapes that is well over a million short-lived objects per request. "Reputable dependency" is not the same as "cheap on a path called thousands of times a request."
Three plausible fixes I rejected, and the one-line reason each didn't survive contact with the evidence.
Cache slot results in Redis
RejectedAvailability changes every time any slot is booked. Cache invalidation would cost more than it saved — a small in-process LRU for stable data is all that remains.
Add more worker threads
RejectedThe pods have 2 vCPUs and the work is CPU-bound. More threads than cores buys context-switching, not parallelism.
Keep the scheduling library, skip its internals
RejectedIts API forces date strings through moment.js internally. There's no way to use it without the allocation cost — so it had to go.
Fix in order of proven impact — not in order of what's interesting to build.
Replace the library on the hot path
The real, production-wide bottleneck. A moment-based scheduling library allocated ~1.3M short-lived objects per request; I replaced it with a native Set-based engine — proven equivalent by tracing the library's own source, with a gap-skip that makes the scan O(available minutes).
Let the architecture keep the change small
The per-vertical strategy/factory on a worker-thread pool meant the swap touched one module, not the core — compute already ran off the event loop, and each vertical overrides only what differs. Good structure is what turns a scary perf fix into a surgical one.
Tune the hot path and match concurrency to hardware
Hoisted per-slot allocations out of tight loops, doubled the worker batch size (fewer dispatch round-trips), and pinned the pool to the pods' 2 vCPUs — the dynamic calc always resolved to 2 anyway.
Good architecture is why
a big fix stayed a small one.
One request, four verticals, off the event loop.
The pipeline predates the perf incident — its shape is what made the incident fixable. A factory selects a per-vertical strategy; the CPU-bound work is batched onto a worker pool so the API stays responsive; each store answers only what it owns.
The engine precomputes, once per worker, an O(1) lookup between a minute-of-day and its display form, then turns each schedule into a set of available minutes. A slot is bookable iff every minute of its window is present in every relevant set — plain integer set math, no date-object allocation.
// a slot is valid iff every minute in its window // is available in every schedule's set for (let m = start; m < end; m++) { if (!available.has(m)) { start = m + 1; // gap-skip → O(available minutes) continue outer; } } slots.push(lookup[start]); // O(1), no moment.js
The library was removed everywhere it was used, with results checked against its own logic before rollout — a behaviour-preserving swap, not a rewrite of the surrounding code.
In-process worker threads
Set<string> over Set<number>
No result caching
Documented edge cases I left correct-by-design: allocations spanning midnight overflow past 1439 and no-op; a to:"23:59" boundary correctly excludes a 23:59 start.
Operating it in production surfaced things the first build couldn't have known. Here's the honest revision.
The strategy/factory + worker-pool shape
It's why the fix was surgical. New verticals still plug in without touching the core, and the offload kept the event loop honest throughout.
Guard the hot path against allocation regressions
A path this hot shouldn't quietly start churning a million objects a request. I'd add an allocation / latency budget in CI so a regression trips a guardrail before it ever reaches a patient's page.
Lazy per-month loading for year-horizon requests
"Next available up to 10 months out" still loads more than a first paint needs. I'd stream the first month and fetch the rest on demand.
Short-TTL caching of schedules + Set<number>
Provider schedules change slowly — unlike slots. A short TTL there is safe, and integer sets would shave the intersection further. Both were named in the report as future work, not shipped.
Continuous latency dashboards, so the win stays visible
The improvement was reconstructed from ad-hoc logs. Durable p50/p95 dashboards would make regressions obvious instead of rediscovered.