Custom Booking System With Recurring Appointments: Cost, Timeline and What Breaks
Recurring appointments are the most underestimated feature in scheduling software. Adding a correct recurrence engine to an existing product typically runs $15,000 to $60,000 over 4 to 8 weeks. Building it as part of a first release runs $50,000 to $130,000 over 10 to 16 weeks. A full multi-provider, multi-timezone, calendar-synced platform with payments and no-show handling runs $150,000 to $350,000 over 6 to 12 months. Those are Digital Heroes delivery bands across 2,000+ projects. The recurrence rules are the cheap part. Timezone correctness, exception handling, calendar sync conflicts and double-booking prevention are where the budget goes.
What a recurring appointment looks like once real users touch it
A physiotherapy clinic in Chicago books a patient every Tuesday at 9:00 AM for 12 weeks, starting in February. The developer stores a start time in UTC and adds 604800 seconds per occurrence. It works. Everyone signs off.
On the second Sunday in March, the United States moves to daylight saving time. Every appointment from that week onward is now at 8:00 AM Chicago time. The patient shows up at 9:00. The therapist has already seen someone else. The clinic calls, and you discover the same bug hit 340 other series, because 604800 seconds is not the same thing as "next Tuesday".
A recurring appointment is a rule, evaluated in a named timezone, that produces occurrences, each of which can be individually modified, cancelled, rescheduled, or orphaned when the rule itself changes. The naive version stores rows. The correct version stores a rule plus a set of exceptions and materialises occurrences on demand or in a bounded window. That choice is the first real architectural decision, and getting it wrong is a rewrite rather than a patch.
RFC 5545 recurrence rules, and why you cannot invent your own
The standard is RFC 5545, the iCalendar specification, and its RRULE property. It exists because "every second Tuesday" carries more ambiguity than you think. RRULE gives you FREQ, INTERVAL, BYDAY, BYMONTHDAY, BYSETPOS, COUNT and UNTIL, plus EXDATE for excluded dates and RDATE for one-off dates bolted onto a series.
Teams that roll their own format hit the same walls. "Last Friday of the month" needs BYDAY=-1FR. "The 31st of every month" needs a defined answer for February, and RFC 5545 skips invalid dates, which surprises product owners who expected February 28. "Every other week on Monday and Wednesday" needs the WKST parameter, because the week start day changes which Monday counts as week two. Ask a vendor what WKST does. Silence tells you a lot.
Use a real library: dateutil.rrule in Python, rrule.js in JavaScript, ical4j in Java, recurr in PHP. They handle parsing and expansion. They do not handle timezone binding, which is your job. Store the rule as an RRULE string plus a separate IANA timezone identifier such as America/Chicago, and a local wall-clock start time. Never store the series anchor purely in UTC. Expand in the local zone, then convert each occurrence to UTC for storage and querying.
One more trap: RRULE has no native concept of "this occurrence moved to Thursday". You need an exceptions table keyed on the original occurrence timestamp holding the override. Every serious calendar system does this, which is why Google Calendar's API exposes both recurringEventId and originalStartTime on modified instances.
Timezones, DST, and the two hours that do not exist
Twice a year in most of the US and Europe, a local hour vanishes and a local hour repeats. An appointment at 2:30 AM on a spring-forward date has no valid wall-clock time. One at 1:30 AM on a fall-back date exists twice. Your library will throw, pick one silently, or return two occurrences. You need an explicit policy, and it is a product decision rather than a library default.
The usual correct answers: for a non-existent time, shift forward to the next valid instant. For an ambiguous time, take the first occurrence. Write it in the spec, because support will get the ticket.
The deeper problem is that the IANA timezone database changes. Governments move DST boundaries with weeks of notice. When Brazil abolished DST in 2019, every system with a hardcoded offset for America/Sao_Paulo was wrong for months. For far-dated appointments, a tzdata update can retroactively change what "9:00 AM local" means in UTC. That is exactly why you store the rule and the zone rather than only computed UTC timestamps. If you materialise occurrences into a table, you need a job that re-expands future occurrences when tzdata updates, and a deployment that actually ships tzdata updates rather than pinning a base image from years ago.
Related and expensive: cross-timezone series. A recurring call between a provider in London and a client in Sydney drifts by an hour twice a year, because the UK and Australia change clocks at different times and in opposite directions. The series is anchored to one party's zone. Decide which one, show both, and warn on the weeks where the other side's local time shifts. Nobody quotes for that screen and every B2B scheduling product eventually builds it.
Double-booking, and why your uniqueness constraint is not enough
Two clients hit "book Tuesday 9 AM" within 40 milliseconds. Both read availability, both see the slot free, both insert. You now have a double booking, an angry provider and a refund.
A unique index on (provider_id, start_time) catches the exact-collision case and nothing else, because real appointments have duration. A 60-minute slot at 9:00 and a 30-minute slot at 9:30 overlap and have different start times. In Postgres the right tool is an exclusion constraint on a tstzrange with a GiST index, using btree_gist so you can combine equality on provider_id with the overlap operator on the range. That pushes overlap prevention into the database where the race cannot escape it. MySQL has no equivalent, so you fall back to SELECT FOR UPDATE on a provider row or an advisory lock keyed on provider and day, which serialises booking per provider and is fine until concurrency climbs.
Buffers make it harder. A clinic wants 15 minutes between appointments, and that buffer must sit inside the range you exclusion-check or two appointments will be legal by the constraint and illegal by the business. Store the true appointment range and a separate blocking range that includes buffers and travel time, and constrain on the blocking range.
Now apply that to a series. A 12-week series must be checked against 12 weeks of existing bookings, atomically, before any of it is written. Partial booking is the norm in bad implementations: weeks 1 through 7 succeed, week 8 collides, and the user gets a half-created series with no clear state. Decide up front whether a conflicting occurrence blocks the whole series, gets skipped with an EXDATE, or gets auto-shifted to the nearest free slot. All three are defensible. Not deciding is not.
Calendar sync, and the failure that pages you at 3 AM
The moment you connect Google Calendar or Microsoft 365, you stop owning the truth. Both sides can edit the same series.
For Google Calendar you use events.watch to register a push channel to a webhook URL, and Google expires that channel. The notification carries no event data, only a signal that something changed, so you call events.list with your stored syncToken for an incremental delta. When the token is stale, Google returns 410 Gone and you must do a full resync, which for a busy provider is thousands of events. Handle that path or you will silently stop syncing and find out when a provider misses a session.
Microsoft Graph is the same shape with different names: subscriptions that expire after a maximum of 4230 minutes for calendar resources and must be renewed, delta queries with deltaLink tokens, and a validation handshake on subscription creation that must respond within seconds or the subscription is refused.
The genuinely hard part is conflict resolution on recurring series. A provider drags one instance in Google Calendar, and Google splits it into an exception with an originalStartTime. Meanwhile your system holds an override for that same occurrence entered by a receptionist. Last-write-wins on updated timestamps silently destroys one of them, and because these are two different clocks, the timestamps may lie. What works: designate authority per field rather than per record. Your system owns who the client is, what the service is, and the payment state. The external calendar owns free and busy and nothing else, so edits made there are availability signals rather than appointment changes. Push conflicts to a human queue. If you must auto-resolve, resolve toward whichever side has a payment attached, because unwinding money is worse than unwinding a time.
Then idempotency. Google and Microsoft both retry webhooks. If your handler is not idempotent on the resource id plus change token, a retry storm duplicates appointments and, if you charge cards on booking, duplicates charges. Every webhook handler needs a dedupe key and a short-lived lock, and every outbound charge needs a Stripe idempotency key derived from the occurrence id rather than a random UUID generated per attempt.
Notifications, cancellation windows and the money edge cases
Recurring appointments generate recurring reminders, and reminders are a scheduling problem of their own. A reminder 24 hours before an appointment that moved is a reminder for the wrong time. If you enqueue reminders when the series is created, every reschedule must find and cancel the queued jobs, and most delayed job systems do not offer reliable cancellation by business key. The pattern that holds up: do not enqueue per appointment. Run a sweeper every five minutes that queries appointments in the reminder window, checks a sent flag, and dispatches. Idempotent, self-healing after downtime, immune to reschedules.
Cancellation policy on a series is where product and engineering collide. "Cancel this appointment" versus "cancel this and all future" versus "cancel the whole series" is the classic three-way dialog, and each branch has different billing consequences. If you charge per occurrence, cancelling future occurrences on a prepaid package needs a credit calculation. If you charge upfront for the package, a mid-series cancellation is a partial refund with a policy window. Late-cancel and no-show fees need to know which occurrence they attach to, which means every occurrence needs a stable identity before it happens rather than only after it is booked. That pushes you toward materialising occurrences, which brings back the tzdata problem. There is no free option. Pick, and know why.
Cost and timeline, from Digital Heroes delivery experience
Across 2,000+ projects, recurring scheduling has settled into three honest bands.
A focused build inside an existing product: $15,000 to $60,000, roughly 4 to 8 weeks. Users, providers, auth and a database already exist. We add an RRULE-backed series model, an exceptions table, overlap constraints, the three-way edit dialog, and reminder handling. The low end is a single timezone, one provider type, no external calendar sync. The high end has multi-timezone and payment coupling.
As part of a first release: $50,000 to $130,000 over 10 to 16 weeks. Scheduling as the core of the product: availability rules, provider working hours, holidays, buffers, client booking flow, admin rescheduling, notifications, and one calendar integration.
A full platform: $150,000 to $350,000 over 6 to 12 months. Multi-tenant, multi-location, resource booking beyond people (rooms, equipment), two-way sync with both Google and Microsoft, payments with packages and no-show fees, waitlists, reporting.
What drives this capability's number up, in rough order of impact:
- Two-way calendar sync. One-way push is a week. True bidirectional sync with conflict handling on recurring series is $15,000 to $35,000 on its own and is the single biggest line item most vendors omit.
- Multi-resource booking. The moment an appointment needs a therapist and a room and a machine, the availability query becomes an intersection across resources and each needs its own overlap constraint. Roughly doubles the scheduling engine cost.
- Cross-timezone series. DST-drift warnings, dual display, and a policy decision per series. $5,000 to $12,000.
- Payments attached to occurrences. Packages, prepaid credits, partial refunds on series cancellation, late fees. $10,000 to $25,000, and mostly edge-case work rather than happy-path work.
- Migrating existing appointments. Importing legacy recurring data from a system that stored it badly is archaeology. Budget 2 to 4 weeks if the source has no rule concept and only rows.
What does not move the number much: the calendar UI. A month grid with drag-to-reschedule looks like the expensive part and is the cheap part.
When you should not build this
If your product is a business and scheduling is a feature of it, buy. If scheduling is your product, build.
Concretely: if you are a clinic, a studio, a salon, a coaching practice or a home-services company and you need clients to book recurring slots, use Cal.com, Calendly, Acuity or SimplyBook. Cal.com is open source and self-hostable, has a real API, supports RRULE-shaped recurrence and both Google and Microsoft sync, and its cloud plans start in the mid-teens per user per month at list. Calendly's paid tiers start around the ten to sixteen dollar per seat mark at list. Check current pricing, but the shape holds: you will spend more in the first month of a custom build than in years of licences, and the vendor has already fought every DST bug in this article.
The honest test: if a competitor shipping a better calendar would not lose you a single customer, do not build a calendar.
Build when one of these is true. Your availability logic is not expressible in any vendor's model, which usually means multi-resource intersection, credentialed matching, travel time between physical locations, or capacity rules that shift by demand. Scheduling data is the input to something else you own, such as route optimisation or clinical records, and round-tripping through a vendor API adds a sync layer costing more than the build. You are subject to data-residency or compliance rules the vendor cannot meet, which for US healthcare means a signed BAA and for EU clients means processing location commitments. Or you are selling scheduling itself, in which case buying is not on the table.
The middle path most teams miss: build the availability and recurrence engine, buy the calendar sync. Nylas and Cronofy exist specifically to abstract Google and Microsoft calendar sync behind one API, including webhook renewal and delta-token misery. That trade converts your hardest, most operationally painful subsystem into a line item.
How to brief and vet a developer for this
Brief with decisions, not features. Before you talk to anyone, answer these, because a good developer will ask and a bad one will not. Which timezone anchors a series, the provider's or the client's. What happens to an occurrence landing on a non-existent local time. Does a conflicting occurrence block the whole series, get skipped, or get shifted. Does "cancel all future" refund. Is the external calendar authoritative for anything. What is the longest series you allow, because "repeat forever" and a materialised table do not coexist.
Then vet with questions that have a right answer:
- "Show me how you'd store a weekly Tuesday 9 AM appointment for a Chicago clinic." A UTC timestamp and an interval means they have not shipped this. You want an RRULE string, an IANA zone id, and a local start time stored separately.
- "What does BYSETPOS do, and when have you needed it?" It selects the nth occurrence within an interval, which is how "last weekday of the month" is expressed. Nobody knows this without having implemented recurrence.
- "How do you prevent two concurrent bookings from overlapping?" Application-level checks are the wrong answer. You want exclusion constraints on a range type with GiST, or explicit row locks, plus an acknowledgement that duration makes uniqueness on start time useless.
- "Google returns 410 on our sync token. What happened and what do you do?" The token expired or was invalidated, and the answer is a full resync with a fresh token, ideally rate-aware. Blank stares here mean their calendar sync will break in production and stay broken quietly.
- "A user edits one instance of a series on their phone's native calendar. Walk me through what happens in our system." Listen for exception records, originalStartTime, conflict detection against your own overrides, and a human-in-the-loop or field-level authority answer. "Last write wins" with no qualification means they will lose data.
- "How do reminders survive a reschedule?" The good answer is a sweeper query, not enqueued delayed jobs. If they describe cancelling and re-enqueueing jobs, ask how they find the job to cancel.
- "What's your policy on the tzdata package in production?" Anyone who has been burned answers immediately. Anyone who has not will ask what tzdata is.
Ask for a demo of a recurrence engine they have shipped and make them create a series that crosses a DST boundary in front of you. Five minutes of that is worth more than five pages of proposal.
The evidence behind this guide
Independent findings on why this investment pays off. Every link goes to the primary source.
- Across ten outpatient clinics the mean no-show rate was 18.8%, and the marginal cost of no-shows reached $14.58 million per year for those clinics, at roughly $196 per missed appointment (2008 figures). Source: BMC Health Services Research / PubMed Central (Kheirkhah et al.) (2015) →
- In an RCT, text-message reminders (11.7% missed) were non-inferior to telephone reminders (10.2% missed; difference not significant, within the 2% non-inferiority margin) but far cheaper - total cost EUR 230 for SMS versus EUR 8,910 for telephone over 6 months - making SMS more cost-effective. Source: BMC Health Services Research / PubMed Central (Junod Perron et al.) (2013) →
- McKinsey argues software developer productivity can be measured by combining system-level metrics (DORA and SPACE) with its own outcome-oriented approach, which it reports deploying across nearly 20 tech, finance, and pharmaceutical companies - a claim that sparked significant debate in the engineering community. Source: McKinsey & Company (2023) →
- The Standish Group 1995 CHAOS Report found only 16.2% of software projects fully succeeded; success varied sharply by size, with large-company projects succeeding about 9% of the time versus far higher rates for small projects - best treated as an industry survey, not an audited dataset. Source: Standish Group (1995) →
Rohan advises mid-market and enterprise teams on ERP, CRM and custom software, and has led delivery on dozens of business-software builds.
Writes for Digital Heroes, shipping business software for 2,000+ brands across 55+ countries since 2017.