Durch

Keith Williams

Scheduling is NP-Hard. We need responses in milliseconds.

Cal.com is scheduling infrastructure that must almost never fail. There's a second constraint that gets far less attention. The core problem we solve is NP-hard, and we solve it inside a SaaS web app where a slow response is indistinguishable from a broken one. This post is about how we reconcile the two. Every scheduling algorithm we ship is deliberately greedy, and that choice holds up far better than a greedy solution may suggest in the SaaS world.

The Problem Looks Simple. It Isn’t.

From the outside, a booking page shows the times someone is free. Under the hood, one month of bookable slots is the intersection of every constraint below.

  • Availability schedules defined in the host’s own timezone, often several per host

  • Date overrides that replace the recurring schedule on specific days

  • Out-of-office periods and travel schedules that shift a timezone mid-month

  • Busy times pulled from every connected external calendar

  • Booking and duration limits per day, week, month, and year

  • Minimum notice, buffers, slot intervals, and offsets that shape which start times are even eligible

That’s one host. Each team event type raises the stakes differently.

  • Collective events require all hosts to be simultaneously available, so every host’s full constraint set has to be intersected with every other’s

  • Round robin events require choosing which host gets the booking, subject to weights, fairness, and priorities

  • Managed events push one template across an entire team, so a single configuration change re-shapes the constraint set for every member at once

  • Routing narrows the host pool dynamically, per booker, before any availability is computed

Each constraint alone is easy. The combination is not. Finding an optimal meeting time for a group under individual availability constraints is a well-studied NP-hard problem.

Be precise about where the hardness lives. Enumerating one host’s free slots is polynomial interval math. What's NP-hard is what we're asked to optimize on top of it, like the best common time across a group or the fairest assignment of bookings over a horizon of future bookings. The architecture keeps the two separated. The enumeration stays linear, and the optimization is never allowed to become a search.

NP-hard means no polynomial-time algorithm is known, and unless P equals NP, none exists. Not “we haven’t optimized it yet.” Not “we need better hardware.” Exhaustive search grows exponentially. It feels instant for a 5-person team and takes many seconds for a 3,000-member organization. The largest customers pay for a bad algorithm first.

The Constraints of Real-Time SaaS

The batch world solves this same class of problem with a proper budget. Airline crew scheduling gets hours of solver time on dedicated clusters, and nobody's flight boards while the optimizer runs. A booking page gets a couple hundred milliseconds, and the deadline comes from the booker staring at a spinner, not from our infrastructure.

The obvious escape is to precompute availability in a background job and serve it from a table. The inputs won't sit still for that. Busy times change whenever anyone's calendar changes, every new booking consumes a slot and can fill a limit, and the question is shaped per booker by their timezone, their chosen duration, the window they're browsing, and the hosts routing selected for them. Precomputing every combination is its own combinatorial explosion, and any precomputed answer goes stale the moment a meeting lands on a host's calendar. Stale availability is worse than slow availability, because it offers times that are already taken. So we compute slots at request time, and the caches in front of that computation are short-lived by design.

We run this on Vercel's Fluid Compute because it makes scaling the app easy. Capacity follows traffic without us provisioning anything, instances are long-lived and reused rather than spun up per request, cold starts are mostly gone (pre-warming, bytecode caching), requests share an instance, and waitUntil pushes non-critical work past the response. Billing is dominated by active CPU. Waiting on a calendar API is nearly free, and burning CPU on a combinatorial search is exactly what we pay for.

Greedy by Design

We never exhaustively search the space. At every decision point we make the locally best choice with the information in hand, commit, and move on. No lookahead. No backtracking.

In the SaaS world, greedy has a negative connotation and can sound like the naive option. But for some problems greedy is provably the best you can do. For set cover, a classic NP-hard problem in the same family as group scheduling, greedy achieves a logarithmic approximation ratio, and no polynomial-time algorithm can do better unless P equals NP.

Round-robin host assignment is the clearest example. Assigning “correctly” would mean optimizing over every possible future assignment, the exponential search we refuse to run. Instead, the feasible set is the hosts available at the requested time, and selection is three ordering filters:




Fairness compares each host’s actual bookings over the recent interval to a weighted target share and keeps the hosts furthest behind (highest weight breaks ties). Concretely:




The filter order is a product decision. Fairness runs before priority and priority before recency, and swapping two filters gives the same booking to a different host. Either way the decision is a few linear scans, whether the pool has 5 hosts or 5,000.

Here is what “we refuse to run that search” costs. A round-robin event with 3,000 hosts sits behind routing. A booker answers the routing questions and the pool trims to 40 qualified hosts. A booked host is never removed from the pool, only deprioritized (if everyone else is out of office, the host who just took a booking takes the next one too), so each booking independently has 40 possible assignees.

40 hosts, next 25 bookings

40²⁵ ≈ 10⁴⁰ possible assignments. At a billion checks per second, that search runs for 3.6 × 10²³ years, about 26 trillion times the age of the universe.

40 hosts, next 50 bookings 40⁵⁰ ≈ 10⁸⁰ possible assignments. That is roughly the number of atoms in the observable universe.

The greedy cascade. Three linear passes over 40 hosts. About 100 comparisons, in well under a millisecond.

That’s after routing already discarded 2,960 hosts. Same fairness guarantees, nearly forty orders of magnitude apart in cost.

No single decision has to be perfect, because the system self-corrects. Overload a host today and their shortfall shrinks, so fairness deprioritizes them tomorrow. Corrections ride the same criterion. A host who joins mid-interval is credited with the average bookings per host from before they joined, and a host returning from out-of-office is credited with their share of the bookings taken while away. The shortfall math sees an adjusted count. No recomputed history, no rebalancing pass.

Slot generation is greedy in the same spirit. Normalize every constraint onto one absolute time axis, walk forward once, emit each surviving start time, and never revisit. Even the event type's slot-optimization setting, which decides where the grid starts inside each free window, is one line of arithmetic during the same sweep, not a comparison of layouts.

The trade-off is real, and the corner case is easy to construct. Give one host a narrow availability window overlapping everyone else’s, let greedy hand them the first booking in it, and you’ve blocked the only slot a later, more constrained booking could have used. Exhaustive search would have seen it coming. Greedy doesn't look ahead. We know these cases exist and have to be ok shipping this because we have milliseconds. The shortfall math balances things out over time.

What Linear Actually Looks Like

“We process constraints linearly” is easy to say. Concretely, the move is always the same. Convert everything to intervals on one absolute time axis, sort once, sweep.

Constraints become intervals before anything touches them. A weekly rule isn’t evaluated per candidate slot. It’s expanded once, in one loop over the days of the requested window, into UTC intervals:




That loop is where travel schedules swap the timezone mid-month and daylight saving shifts an offset, so day-independent work is hoisted out of it. Timezone conversion dominates the loop, and it runs once per day per rule, not once per slot.

Collective events are a two-pointer merge, not a cross product. Thirty hosts need the times all thirty are free. Comparing every interval against every interval is quadratic twice over. Instead, each host’s intervals are sorted by start time and folded into a running “common availability” list:




One pointer walks the common list, one walks the next host’s list, and we always advance whichever interval ends first. The core of the real implementation is exactly this small.

while (commonIndex < common.length && userIndex < userRanges.length) {
  const a = common[commonIndex];
  const b = userRanges[userIndex]

while (commonIndex < common.length && userIndex < userRanges.length) {
  const a = common[commonIndex];
  const b = userRanges[userIndex]

while (commonIndex < common.length && userIndex < userRanges.length) {
  const a = common[commonIndex];
  const b = userRanges[userIndex]

Each host costs one linear pass, and the moment the common list goes empty we stop. If hosts one through seven share no time, hosts eight through thirty can't create any.

Busy times are subtracted in one sorted sweep. Bookings, external calendar events, and out-of-office periods all land in one exclusion list, sorted once by start time. Each availability range walks it in order, keeping the gaps:







Because the exclusions are sorted, everything behind the break is never examined.

Booking limits become synthetic busy time. “5 bookings per day” could be checked against every candidate slot, at a cost of slots × limits × bookings. Instead, when the count hits the cap, the whole period becomes one busy interval and rides the same subtraction sweep:




The limit check runs once per period, and the slot loop never learns that limits exist.

None of these steps is individually clever. An NP-hard problem inside a millisecond budget isn't solved by one brilliant algorithm. It's solved by refusing at every step to let any computation depend on the product of two large numbers.

Everything Around the Algorithm Is Bounded Too

A perfect algorithm fed by a slow query is still a slow booking page, and the data layer is where enterprise scale bites first.

Reads are batched, then indexed in memory. The naive shape is a loop over hosts with three queries inside. Thirty hosts means 90 round trips to Postgres before a single interval is computed, invisible for a 5-person team and a production incident for 3,000. The slots pipeline fetches everything up front instead:




Bookings and out-of-office days load concurrently under one Promise.all, and limits follow in a second batched round. Results are grouped once into Map<userId, rows> lookups, and from then on availability math touches no database. The per-host function is typed to require the pre-fetched data, so nobody can quietly reintroduce a per-user query without the compiler complaining.

External calendars get a cache with strict rules. A single provider round trip dwarfs the entire interval computation, so the cache is built around three rules:




One miss pays for every subsequent read anywhere in the horizon. And a failed provider call is never cached, because caching an error as “free for the whole horizon” would silently offer slots on top of real meetings. An outage costs a retry, never a wrong answer.

Date handling is hot-path code. Timezone-aware arithmetic in a per-slot loop dominates everything else, so conversion happens once at the boundary and the inner loops compare plain UTC millisecond values.

Time budgets are tested, not assumed. Unit suites prove each algorithm finishes within the time constraint we have at enterprise scale, not just that it returns the right answer, so a complexity regression fails in CI instead of in production.

Where We’re Honest About Not Being There Yet

Everything above is the discipline, not a finished result. Here is the gap, measured on my own 15-minute event against the production API while writing this post:




The computation is within budget. The full page is only within budget when the caching in front of the provider calls does its job. Teams are further behind, and for more than one reason. We run the Google calls for all hosts in parallel, so the external cost stays roughly flat while the computational side grows with the team. The database adds its own drag. Team availability pulls link configuration, bookings, limits, and out-of-office rows for every host from some tables holding millions of records, and some of those queries are still slow.

Round robin is where we're attacking the team gap first, because it has a property the general problem doesn't. A collective event needs every host free, but a round robin booking needs exactly one. The booking page doesn't need to know who is available at each slot, only whether anyone is. So we're building a higher-level cache that stores, for a month of slots, the count of available hosts computed once. Every booking decrements the counts it affects, and the full recomputation only runs when a slot's count gets low enough that it could actually run out of hosts. Most slots never get there. The expensive question, which host takes the booking, is still answered greedily at booking time. The cheap question, whether anyone is free, becomes a counter read.

Every Booking Feature Is an Algorithmic Decision

Every booking feature we ship has to answer a question most SaaS products never ask. Does it explode the search? A feature that can be expressed as another ordering filter in the greedy cascade is nearly free at any scale. One that forces us to compare combinations gets redesigned until it doesn’t.

Greedy sometimes picks a worse option than an exhaustive search would have, and that will always be true. We accept it, bound it, and correct for it over time, because the exhaustive answer only arrives after the booker has given up waiting. The trade already works for individual booking pages. Making it faster for a 3,000-member organization is the work we’re doing now.

Beginnen Sie noch heute kostenlos mit Cal.com!

Erleben Sie nahtlose Planung und Produktivität ohne versteckte Gebühren. Melden Sie sich in Sekunden an und beginnen Sie noch heute, Ihre Planung zu vereinfachen, ganz ohne Kreditkarte!