Skip to content
Work/Case StudyFlagship
Performance Engineering · Distributed Systems

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.

Role
System design + solo perf work
Stack
NestJS · Couchbase · Elasticsearch · Piscina
Timeframe
2025 build → Jun 2026 optimization
Headline result
~12s → <1s · measured
OwnershipImplementation · HighDesign · HighTech Leadership · HighOperational · HighConfidence · Very High
01 — Background

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.

02 — Problem

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.

03 — Constraints
01
Interactive latency on CPU-bound work. A human is waiting on combinatorial computation — the two hardest requirements to hold together.
02
A single event loop. One heavy request — a large practice over a long horizon — can head-of-line block every other request on the pod.
03
Data split across two stores. Appointments and availabilities live in Elasticsearch; providers, operatories, and custom hours in Couchbase (N1QL).
04
Per-vertical divergence. Each vertical queries and filters differently — the engine can't assume a single shape.
04 — Investigation

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.

Fig 1 · Partitioning the request
① data fetch · ES + Couchbase
fast
② per-date compute · the library
~1.3M allocs
③ batch dispatch · worker pool
fast
our algorithm: ~11 msthe cost was one layer down — in the library it delegated to
Instrument to partition the request, not to confirm a hunch. The heavy layer was compute — but inside it, the reputable library, not the algorithm the team feared.

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."

05 — Alternatives considered

Three plausible fixes I rejected, and the one-line reason each didn't survive contact with the evidence.

Cache slot results in Redis

Rejected

Availability 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

Rejected

The 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

Rejected

Its API forces date strings through moment.js internally. There's no way to use it without the allocation cost — so it had to go.

06 — Decision

Fix in order of proven impact — not in order of what's interesting to build.

1

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).

2

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.

3

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.

07 — Architecture

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.

Fig 2 · Booking execution pipeline
Booking widget
GET /availability
NestJS API
event loop stays free
select strategy
StrategyFactory
dentalorthochirooptometry
AbstractSlotStrategy · override only what differs
dispatch batch
Piscina worker pool2 threads = 2 vCPUs
native set-intersection engine
slot valid ⟺ every minute in window ∈ every schedule's set · O(1) minute lookup · gap-skip
Couchbase
providers · operatories · hours · indexed
Elasticsearch
appointments · availabilities
New vertical = new strategy, no core edits. The worker offload is architecture; the index and the native engine were the fix.
Implementation · in the code

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.

slot engine · simplified
// 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.

08 — Trade-offs
Chose

In-process worker threads

Not buying
A separate microservice — kept compute and data-fetch co-located; no network hop.
Chose

Set<string> over Set<number>

Not buying
A marginally faster intersection — kept string keys for consistency with downstream operatory/provider IDs. Named as future work.
Chose

No result caching

Not buying
Cheap repeat reads — availability changes continuously, so stale slots would be worse than slow ones.

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.

09 — Measured outcome
measured on betaestimated
Fig 3 · Latency, before vs after
Total API response ~92% ↓
~12,000ms
<1,000ms
Object allocations / request GC pressure removed
~1.3M
~0
Two levers, two different costs: replacing the library removed the allocations, and the worker-thread offload keeps the API responsive under concurrency. Production p50/p95 under real concurrency is the next thing worth pulling. Team feedback: "very, very fast."
10 — Lessons learned
Good architecture decides the size of the fix. Because per-vertical logic sat behind a clean strategy boundary and compute already ran off the event loop, a platform-scale performance problem became a one-module change — not a rewrite. Structure you invest in early is what buys you a surgical fix later.
Ergonomic libraries can be hot-path traps. sscheduler + moment were fine at low frequency and a liability at 1.3M allocations per request.
Prove equivalence when you replace a library — don't just swap it. I traced the source to show the native engine returns identical results across every edge case before shipping it.
Decompose a win into independent levers. The index, the engine, and the concurrency tuning were separable — so each could be measured and reasoned about on its own.
11 — If I rebuilt this today

Operating it in production surfaced things the first build couldn't have known. Here's the honest revision.

Keep

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.

Change

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.

Change

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.

Consider

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.

Learned

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.

© 2026 Sukhcharan SinghCase study · The Booking Availability Engine