Design a scheduling link like Calendly
A public booking page where a host shares one link and an invitee anywhere in the world picks a free time, and both get a calendar event with a video link. The hard parts are computing free times from weekly rules, connected calendars, buffers and limits on the server, showing them correctly across time zones and daylight saving changes, and booking a slot that someone else may have taken a second ago.
The question
Design a scheduling link like Calendly. A host connects their Google or Microsoft calendar, sets the hours they take meetings, and shares a link such as
cal.example.com/priya/30min. An invitee opens it, sees the free times in their own time zone, picks one, types their name and email, and both of them get a calendar event with a video link.The host is in Bengaluru and the invitee might be in London or San Francisco. Walk me through how free times are computed, how they are shown across time zones and clock changes, what happens when two people pick the same time, what happens after the booking, and how the page is embedded on the host's own website.
Real-life examples: Calendly, Cal.com, Google Calendar appointment schedules, HubSpot Meetings, Microsoft Bookings
Requirements, scope, and what makes it hard
A scheduling link replaces the back-and-forth emails of 'does Tuesday at 3 work for you?'. The host defines when they are willing to meet once. The product then answers, for any invitee at any moment, the question which times are free right now, in my time zone? and turns one of those times into a real event in the host's calendar. Two people, two calendars, two time zones, and one shared moment.
This guide is about the booking side: the public page the invitee sees, and the server work that feeds it. The host's own calendar UI is in the Google Calendar guide, and the week grid in the calendar week view guide.
Start with the questions whose answers change the design, and the answer this guide assumes for each.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who is the user of this page? | The invitee: a stranger with no account, often on a phone, often clicking from an email. | No sign-in. The page must be fast on a first visit and work without any stored state. |
| Where do busy times come from? | The host connects one or more Google or Microsoft calendars. Busy events in any of them block slots. | The server reads free/busy from external APIs with their own latency and rate limits, so caching matters. |
| What rules can the host set? | Weekly hours in the host's time zone, date overrides, buffers before and after, minimum notice, how far ahead, a daily limit, slot interval and meeting length. | Availability is a pipeline of rules; slots are computed on demand, not stored. |
| Who can book at once? | Anyone with the link. A popular host can get many invitees looking at the same week. | Two people can pick the same time. The server is the only judge, at booking time. |
| One host or a team? | One host first. Teams (everyone must attend, or the meeting goes to one of several hosts) as an extension. | Team modes change the availability math: intersection or union of calendars. |
| Does the page live on our site only? | No. Hosts embed it on their own website as an inline frame or a popup. | An iframe embed with auto-resize and events for the parent page. |
| What happens after booking? | An event in the host's calendar with the invitee as attendee, a video link, a confirmation email with a calendar file, reminders, and links to reschedule or cancel. | A booking is a small workflow with several external calls, not one database insert. |
Functional requirements
- Open a link for one meeting type (for example '30 minute intro call') and see a month calendar with the days that have free times.
- Pick a day and see the free start times, shown in the invitee's detected time zone, with a switcher to change zone and a 12-hour or 24-hour display.
- Pick a time, answer a few questions (name, email, maybe 'what do you want to discuss?'), and confirm.
- Get a confirmation screen and an email with the event, a video link, and links to reschedule or cancel.
- If the time was taken in the meantime, be told so plainly and shown fresh times, without losing the typed answers.
- Work the same way when embedded on the host's own website.
Non-functional requirements
- Correct times, always. A slot shown as 10:00 in London must be the same instant the host sees in Bengaluru, on every week of the year, including the weeks when clocks change. A wrong time is worse than a slow page: two people miss a meeting.
- Never double-book. If two invitees confirm the same slot, exactly one wins, and the other gets a clear message and new choices.
- Privacy. The invitee sees only free start times. Never event titles, attendees, or even the exact shape of busy blocks from the host's calendar.
- Fast first view. The invitee clicked a link once and has no patience. Aim for the month of days within about 1 second on a mid-range phone on 4G, and the times for a day within 100 ms of a click (they should already be loaded).
- Fresh enough. Busy times change when the host adds a meeting. Slots shown may be up to a minute or so old; the booking itself must use fresh data.
- Accessible. The date grid and the time list work with a keyboard and a screen reader, and availability is never shown by colour alone.
Out of scope
- The host's settings screens and their own calendar view: they are forms and a calendar UI, covered by other guides.
- Payments for paid sessions: a checkout step after the time is picked, covered in the checkout flow guide.
- OAuth sign-in and the consent screen for connecting a calendar: covered in the auth flow guide. We assume the server holds a valid token per connected calendar.
- Group events with many seats per slot: that is capacity booking, covered in the grocery slots guide.
What makes this hard
The booking page looks like a date picker with a list of buttons. Four facts make it much harder than that, and the chapters that follow answer them one by one.
- Slots are derived, not stored. There is no table of free slots. A slot exists only as the result of a calculation: the host's weekly rules, minus the busy times in two or three external calendars, minus buffers, cut by notice and limits. Change any input (the host accepts a meeting in Outlook) and the answer changes, without our system being told.
- Two time zones on every screen. The rules are written in the host's local time ('Monday 09:00 to 17:00, Asia/Kolkata'), and the invitee reads the result in theirs. The offset between the two is not a constant: it changes on the dates when either side's clocks change, and those dates differ between countries.
- The truth lives in someone else's system. Google Calendar and Microsoft 365 are the source of truth for 'busy'. Calling them takes 100 to 500 ms (an estimate; it varies by provider and region), they rate-limit, and they can be down. Our page must be fast while depending on slow, external data.
- Two strangers race for one time. Unlike a shared document, invitees never see each other. Two people can view the same free 10:00 and both click confirm within seconds. Only one can have it, and the other must find out kindly.
Compare it with nearby problems. A grocery slot picker sells capacity counted in our own database (30 vans per window), and a seat map sells a fixed, known set of seats. Here the 'stock' is one person's time, and the record of who is busy lives outside our database. So most of the design is about computing availability correctly and re-checking it at the last moment, not about holding and counting.
The wireframe shows the whole UI: who you are meeting on the left, a month calendar with only the days that have free times enabled in the middle, and the times for the chosen day under a time zone switcher on the right. After a time is picked, the right side becomes a short form. The difficulty is in what feeds this screen.
Check your understanding · Exercise 1
Which answers from the scope questions change the design the most? Select all that apply.
Computing availability on the server
Start with the core question: given a host, a meeting type, and a range of dates, which start times are free? The first decision is where that is computed, because it decides what data crosses the network.
Decision Where should free slots be computed?
What we pick: Compute on the server and return only free start times. Privacy alone decides it: the page is public, so it may only show what the host agreed to show. Keep a short-lived cache of busy data under the computation, and use change notifications to clear that cache early, not to maintain a full slot table.
The availability pipeline
Think of availability as a pipeline that starts wide and only ever removes time. Each step is simple; the order matters.
- Weekly rules: for each date in range, the host's working windows in the host's time zone, for example Monday to Friday 09:00 to 17:00 in
Asia/Kolkata. Converted to UTC instants per date (the next chapter shows why this must be per date). - Date overrides: replace the windows for specific dates ('only 14:00 to 16:00 on 24 December', 'unavailable on 2 October').
- Range limits: drop everything before
now + minimum notice(say 4 hours) and aftertoday + maximum days ahead(say 60 days). - Candidate starts: walk each window in steps of the slot interval (say every 30 minutes) and keep starts where the whole meeting fits in the window.
- Busy times: drop a candidate if the meeting, widened by the buffers (say 15 minutes after), overlaps any busy interval from any connected calendar or any existing booking.
- Daily limit: if the host already has the maximum meetings of this type that day (say 4), drop the whole day.
type Interval = { start: number; end: number };
function slotsForDay(windows: Interval[], busy: Interval[], booked: number, o: Opts): number[] {
if (booked >= o.dailyLimit) return [];
const earliest = o.now + o.noticeMs;
const out: number[] = [];
for (const w of windows) {
for (let t = w.start; t + o.lengthMs <= w.end; t += o.stepMs) {
if (t < earliest || t > o.latest) continue;
const guard = { start: t - o.beforeMs, end: t + o.lengthMs + o.afterMs };
const clash = busy.some((b) => b.start < guard.end && guard.start < b.end);
if (!clash) out.push(t);
}
}
return out;
}
A worked example. Priya works 09:00 to 17:00 in Bengaluru and offers 30 minute calls every 30 minutes, with 15 minutes of buffer after each. On Monday her calendar has one busy block from 11:00 to 12:00. The window gives 16 candidates, from 09:00 to 16:30, which ends exactly at 17:00.
The candidate at 10:30 ends at 11:00, but its guard runs to 11:15 and overlaps the busy block, so it is dropped; 11:00 and 11:30 overlap the block itself. The candidate at 10:00 has a guard ending at 10:45 and survives, and so does 12:00, because there is no buffer before it. Monday offers 13 slots out of 16: 09:00 to 10:00, then every half hour from 12:00 to 16:30.
Reading busy times from calendars
Both big calendar providers have an endpoint built for exactly this. Google Calendar has freebusy.query: a POST to /calendar/v3/freeBusy with timeMin, timeMax, and a list of calendar ids, answering with only a busy list of start and end times per calendar.
Microsoft Graph has getSchedule: a POST to /me/calendar/getSchedule with a list of schedules (email addresses), a start and end time, and an availabilityViewInterval in minutes (default 30, from 5 to 1440). It answers with schedule items, each with a status (free, tentative, busy, out of office, working elsewhere). Times come back in UTC unless a Prefer: outlook.timezone header asks for another zone.
Use these rather than listing events, and fetch the whole visible month in one call per provider, not one call per day. Google has a free/busy-only scope (calendar.freebusy), so the host grants less and no event details come back. Graph needs Calendars.Read, and its schedule items can include the subject and location when the calendar's sharing settings allow it, so the connector drops those fields before caching anything. Know the limits: Graph accepts up to 20 schedules and a period shorter than 62 days per call, and Google's calendarExpansionMax tops out at 50 calendars.
Caching busy times
A popular host's page can be opened hundreds of times a day, and each open would otherwise call Google or Microsoft. Cache the busy list per calendar and per month with a short time to live (TTL): 30 to 60 seconds is a reasonable starting estimate. Subscribe to change notifications (Google push channels, Microsoft Graph subscriptions) and delete the cache entry when one arrives, so a host's new meeting shows up in seconds. Cache the inputs (busy lists, rules), not the output (slots), because slots also depend on now through the minimum notice.
When a provider is slow or down, do not show a page full of slots built on missing data. Serve the last cached busy list if it is a few minutes old at most, and otherwise show 'cannot load times right now, try again' with a retry. A wrong 'free' leads to a booking that clashes; the booking check in a later chapter is the final guard, but it should rarely have to catch this.
Team scheduling, briefly
Two team modes reuse the same pipeline. Collective: everyone must attend, so a slot is free only if every member is free (the intersection of their slot sets). Round robin: any one member can take it, so a slot is free if at least one member is free (the union).
At booking time in round robin, the server picks the member, by priority or by who was booked least recently, and records the choice. Both modes multiply the calendar calls by the team size, which is another reason the busy cache matters.
- Host's view. New York (EDT): Tue 10:00, Tue 12:00 to Tue 16:30
- Kolkata (IST): Tue 19:30, Tue 21:30 to Wed 02:00
- London (BST): Tue 15:00, Tue 17:00 to Tue 21:30
- San Francisco (PDT): Tue 07:00, Tue 09:00 to Tue 13:30
Tue 20 Oct: 11 start times. New York (EDT): Tue 10:00, Tue 12:00 to Tue 16:30. Kolkata (IST): Tue 19:30, Tue 21:30 to Wed 02:00. London (BST): Tue 15:00, Tue 17:00 to Tue 21:30. San Francisco (PDT): Tue 07:00, Tue 09:00 to Tue 13:30.
Check your understanding · Exercise 2
Priya offers 30 minute calls every 30 minutes from 09:00 to 17:00, with a 15 minute buffer after, and has a busy block 14:00 to 15:00. Which start times are offered? Select all that apply.
Time zones and daylight saving
Every slot is one instant shown to two people in two zones. The naive design stores the host's rules as UTC times: 'Priya is available 03:30 to 11:30 UTC'. That works for Priya, because India has no daylight saving.
Now take Sam, who works 09:00 to 17:00 in New York. Store that as 13:00 to 21:00 UTC in October and, after the clocks go back on 1 November, the page shows Sam free from 08:00 to 16:00 New York time. Sam gets meetings at 08:00 that they never offered.
Decision How should rules and bookings store time?
What we pick: Store rules as wall time plus an IANA zone name such as Asia/Kolkata, and convert them per date. Store each booking as a UTC instant plus both zone names. The browser receives instants and only ever formats them for display.
One slot, three clocks
Priya's 09:00 to 17:00 in Kolkata (UTC+05:30, all year) is 03:30 to 11:30 UTC. In London in winter (UTC+00:00) that is 03:30 to 11:30; in summer, under British Summer Time, 04:30 to 12:30. In San Francisco in winter (UTC-08:00) it is 19:30 to 03:30, starting on the previous evening. So Priya's Monday shows up for a Californian as Sunday evening plus the first hours of Monday.
That has a direct UI consequence: the month calendar must group slots by the date in the invitee's zone, not the host's. A day that is empty for the host (Sunday) can have times for the invitee, and one host day can split across two invitee days.
So the server returns a flat list of UTC instants, and the browser groups them by local date in the selected zone. Changing the zone switcher regroups the same data with no new request, as long as the fetched range covers the edges of the month in the new zone.
The same rule on different weeks
Clock changes happen on different dates in different places. In 2026 the UK goes back an hour on 25 October, and the US on 1 November. Take Sam, 09:00 New York time, seen from London:
| Week of | New York | London | Sam's 09:00 in UTC | Shown in London |
|---|---|---|---|---|
| 19 Oct 2026 | EDT, UTC-04:00 | BST, UTC+01:00 | 13:00 | 14:00 |
| 26 Oct 2026 | EDT, UTC-04:00 | GMT, UTC+00:00 | 13:00 | 13:00 |
| 2 Nov 2026 | EST, UTC-05:00 | GMT, UTC+00:00 | 14:00 | 14:00 |
The rule never changed, yet the London invitee sees 14:00, then 13:00, then 14:00. This is correct, and it only works if the rule is converted per date with the zone database. Any code that computes 'the offset between host and invitee' once and applies it to the whole month is wrong for one or two weeks a year, and those are exactly the weeks people book meetings without double-checking.
function windowStart(date: string, time: string, zone: string): string {
const zoned = Temporal.PlainDate.from(date).toZonedDateTime({
timeZone: zone,
plainTime: Temporal.PlainTime.from(time),
}); // disambiguation defaults to "compatible"
return zoned.toInstant().toString();
}
windowStart("2026-10-26", "09:00", "America/New_York"); // "2026-10-26T13:00:00Z"
windowStart("2026-11-02", "09:00", "America/New_York"); // "2026-11-02T14:00:00Z"
Missing and repeated hours
On the night the clocks go forward, an hour does not exist: in New York on 8 March 2026, the clock jumps from 01:59 to 03:00, so 02:30 never happens. On the night they go back, an hour happens twice: on 1 November 2026, 01:30 occurs once in EDT and again an hour later in EST. A host whose rule starts or ends in that hour (a night support desk, '01:00 to 03:00') needs a decision.
- Convert the window's start and its end to instants separately, then walk candidates in real elapsed minutes between them. On the short night the window is 1 hour long; on the long night it is 3. Both are honest.
- For a wall time that falls in the gap, move it forward (02:30 becomes 03:30). For a wall time that happens twice, take the earlier one. This is what Temporal's default
compatibledisambiguation does, and what RFC 5545 specifies for calendar files, so the page agrees with the invitee's calendar app. - When the list of times for a day contains the repeated hour, show the zone abbreviation next to those times (01:30 EDT, 01:30 EST), so two buttons never have the same label.
Detecting and formatting in the browser
The browser knows the invitee's zone: Intl.DateTimeFormat().resolvedOptions().timeZone returns an IANA name such as Europe/London. Use it as the default, show it in plain words above the time list ('Times in London (GMT)'), and offer a searchable switcher, because people book from a laptop set to their home zone while travelling, or book on behalf of someone else. Keep the chosen zone in the URL (?tz=America/Los_Angeles), so a shared or reloaded link shows the same times; the URL as state lesson covers why.
One detail catches people out: engines can report an older alias for the same zone. Chrome, Safari, and Node have returned Asia/Calcutta where Firefox returns Asia/Kolkata, because they read zone names from different data. So the server accepts any IANA name, aliases included, and code never compares two zones as plain strings.
Format with Intl.DateTimeFormat and its timeZone option, never by adding offsets by hand. The 12-hour or 24-hour choice is the hourCycle option (h12 or h23); default it from the locale and let the invitee flip it. The locale formatting lesson covers the rest of Intl.
The Temporal API is the modern way to do the date arithmetic. It shipped in Firefox 139 (May 2025) and Chrome 144 (January 2026), but at the time of writing (late 2026) no stable Safari release supports it. So a public page that needs arithmetic in the browser should load a polyfill or keep that arithmetic on the server. This design keeps it on the server, and the browser only formats.
Check your understanding · Exercise 3
A London invitee sees Sam's first slot at 14:00, then 13:00 the next week, then 14:00 again. Which statements are true? Select all that apply.
The booking page: loading and showing times
The invitee's path is short: see the month, pick a day, pick a time, fill a form, confirm. The design goal is that every step after the first is instant, because the data for it is already in memory. The first decision is how the page gets its first slots.
Decision How does the first view get its slots?
What we pick: Render the shell on the server and fetch instants from the browser. The trick that makes it fast: request the month padded by one day on each side, in UTC. Every zone on Earth is within 14 hours of UTC, so the padded range covers the visible month in any zone, and the request can start before the app knows which zone it will use.
One month at a time, the next one prefetched
Fetch slots for the visible month only. A 60 day range of 30 minute slots for a full-time host is about 700 instants (an estimate: 42 working days times 16 slots), around 20 KB of JSON before compression. One month is half that, and it is also half the calendar API work on the server.
When the month arrives, prefetch the next one in the background (on idle, or when the pointer moves toward the next month button), so the 'next month' click is instant. Keep both in a small cache keyed by host, meeting type, and month, with a freshness of about a minute; the server state libraries lesson covers this pattern.
Month changes can race: an invitee clicks 'next' three times quickly. Abort the old request with an AbortController and ignore any reply whose month is not the one on screen. The request races lesson covers the general fix.
What lives where
- URL: the month, the selected date, the zone, and the selected slot:
?month=2026-11&date=2026-11-03&tz=Europe/London. Reload and share keep the view, and the browser back button goes from the form back to the times. - In-memory cache of server data: the slot lists per month, with their fetch time.
- Component state: the hour format toggle (also saved in
localStorage), the open state of the zone switcher, and the form fields. - Derived, never stored: the days with times and the times for the selected day. Both are computed from the instants plus the zone, so changing the zone can never leave them out of sync.
function groupByLocalDate(slots: string[], timeZone: string) {
const key = new Intl.DateTimeFormat("en-CA", {
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
});
const days = new Map<string, string[]>();
for (const iso of slots) {
const day = key.format(new Date(iso)); // "2026-11-03"
const list = days.get(day) ?? [];
list.push(iso);
days.set(day, list);
}
return days; // the calendar enables only keys present here
}
Empty states
Empty is common: a busy host, a short booking window, or an invitee opening the link late in the month. A grid of disabled days says nothing. Ask the server to include nextAvailable, the first free instant after the range, whenever the range is empty or nearly empty.
Then the page can say 'No times in October. The next available time is Tuesday 3 November' with a button that jumps there. On first load, if the current month has nothing left, open the next month with times directly. If nothing is free within the host's booking window at all, say so and offer the host's contact link instead of an endless calendar.
Check your understanding · Exercise 4
Which of these invitee actions need a new slots request to the server? Select all that apply.
Booking without double-booking
Ana in Lisbon and Ben in Toronto both open Priya's link at 10:00 UTC, and both see Thursday 11:00 UTC as free. Ana fills the form in 40 seconds, Ben in 25, and meanwhile Priya accepts an unrelated invite in Google Calendar for Thursday 11:00. Three parties want one half hour, and our database knows about only two of them. The naive endpoint, 'insert a booking for the time the client sent', gives Priya three meetings at once.
Decision How do we make sure a slot is booked at most once?
What we pick: Re-check and create atomically at confirm. The form is short (name, email, maybe one question), so the window for a clash is seconds, and only a handful of people compete for one host's half hour. A hold can sit on top of the check, never replace it: Cal.com, for example, reserves a selected slot for a few minutes (5 by default in its API), but a hold still cannot see the host's own calendar. The grocery slots and seat map guides show when holds are worth their full cost: long checkouts and many buyers chasing scarce capacity.
What the server does on confirm
- Idempotency: look up the
Idempotency-Keyheader. If this key already produced a booking, return that booking again and stop. - Validate: the slot must fall on the meeting type's interval grid, inside the booking window, and the answers must pass the form rules.
- Fresh check: call the calendar providers for this one slot plus its buffers, bypassing the cache, and re-run the same pipeline code as the slots endpoint. This takes 100 to 500 ms (an estimate), which is acceptable once per booking.
- Atomic insert: insert the booking in a transaction. A database rule guarantees no two confirmed bookings for one host overlap. In PostgreSQL that is an exclusion constraint on the host id and the time range; in other databases a row lock on the host while checking and inserting.
- Answer:
201 Createdwith the booking, or409 Conflictwith an error code and the fresh times for that day. Calendar event, video link, and emails happen after the commit (next chapter).
CREATE TABLE bookings (
id uuid PRIMARY KEY,
host_id uuid NOT NULL,
during tstzrange NOT NULL, -- start and end, with buffers
status text NOT NULL, -- confirmed | cancelled
idem_key text UNIQUE,
EXCLUDE USING gist (host_id WITH =, during WITH &&) -- needs btree_gist
WHERE (status = 'confirmed')
);
The retry that must not fail
Ana taps confirm on a train. The server books the slot, but the response is lost when the train enters a tunnel, and her browser retries. Without an idempotency key, the server runs the check again, finds the slot taken (by Ana herself), and answers 409: Ana sees 'this time is no longer available' for a meeting she just booked, and may book a second one.
With the key, the server recognises the request and returns the original booking. Generate the key once, when the form is submitted for this slot, and reuse it on every retry of that submit. The retries and backoff lesson covers when to retry; the API contracts lesson covers the error format.
async function confirm(draft: Draft): Promise<Result> {
draft.idemKey ??= crypto.randomUUID();
const res = await fetchWithRetry("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": draft.idemKey },
body: JSON.stringify({ eventTypeId: draft.eventTypeId, start: draft.slot,
timeZone: draft.timeZone, answers: draft.answers }),
}); // retries network errors and 503 only, never 409
if (res.status === 201 || res.status === 200) return { kind: "booked", booking: await res.json() };
if (res.status === 409) {
const body = await res.json(); // { code: "SLOT_TAKEN", freshSlots: [...] }
return { kind: "taken", freshSlots: body.freshSlots };
}
return { kind: "error", status: res.status };
}
The 409 screen
When the slot is gone, keep the invitee's answers, go back to the time list for the same day, replace the cached slots with the fresh list from the 409 body, and say what happened in one line: 'Sorry, 11:00 was just booked. Here are the other times on Thursday.' Move focus to that message so a screen reader announces it. If the day has nothing left, show the next available day. The invitee picks again and confirms in one tap, because the form is already filled.
submitting are not failures of the app: taken is a normal outcome with fresh times, and error retries with the same key, so a lost response can never turn into a second booking.- 0 ms: Ana clicks confirm (Idempotency-Key key-ana-1).
Check your understanding · Exercise 5
Ana's confirm request succeeded on the server but the response was lost. Her browser retries. What should happen? Pick one.
After the booking: event, video link, email, and links
A confirmed row in our database is not yet a meeting. Both people need it in their calendars, with a video link, and a way to change it. That is four or five calls to outside services, each of which can be slow or fail. The decision is whether the invitee waits for them.
Decision When do the calendar event, video link, and emails get created?
What we pick: Use an outbox. The booking is the promise; everything else is delivery of that promise and can be retried. The confirmation screen shows the time in both zones, the host, and 'a calendar invitation is on its way to ana@example.com', and polls briefly for the video link if you want to show it.
The calendar event and the video link
The worker creates the event in the host's calendar through the provider's API, with the invitee as an attendee. Google Calendar can create a Meet link as part of the event (a conferenceData.createRequest on the event, with conferenceDataVersion=1 on the request), and Microsoft Graph can mark the event as an online meeting with Teams.
For Zoom, the worker first creates a meeting through Zoom's API and puts its join URL in the event's location and description. Store the provider's event id on the booking: rescheduling and cancelling must update that same event, not create new ones.
Pick one sender for the invitation. If the provider emails the attendee and we also send our own email with a calendar file, the invitee gets two invitations and may add the meeting twice. Either let the provider invite the attendee (Google's sendUpdates=all) and keep our email to the manage links, or send our own calendar file and create the host's event with sendUpdates=none. Google's docs warn that none can leave guests on other calendar systems out of sync, so with the second option every update must go out through our own email too.
The calendar file
When our email carries the invitation, it attaches an iCalendar file (.ics, defined in RFC 5545), which every calendar app can import. Every VEVENT needs a UID and a DTSTAMP, and an invitation (METHOD:REQUEST, defined by iTIP in RFC 5546) also needs DTSTART, an ORGANIZER, at least one ATTENDEE, and a SUMMARY. Write DTSTART and DTEND in UTC (the Z suffix): a local time with a TZID would need a matching VTIMEZONE definition in the file, and the invitee's calendar app shows the UTC time in their own zone anyway.
The UID must be stable for the booking. A reschedule sends the same UID with a higher SEQUENCE, and a cancel sends METHOD:CANCEL with the same UID and a higher SEQUENCE again. Calendar apps then update the existing entry instead of adding a second one.
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Scheduling//EN
METHOD:REQUEST
BEGIN:VEVENT
UID:bk_7f3a9c@cal.example.com
DTSTAMP:20261022T101500Z
SEQUENCE:0
DTSTART:20261105T110000Z
DTEND:20261105T113000Z
SUMMARY:Intro call: Priya Rao and Ana Costa
ORGANIZER;CN=Priya Rao:mailto:priya@example.com
ATTENDEE;CN=Ana Costa;RSVP=TRUE:mailto:ana@example.com
LOCATION:https://meet.example.com/abc-defg-hij
END:VEVENT
END:VCALENDAR
Reschedule and cancel links
The invitee has no account, so the link in the email is their only key. Make it a capability URL: cal.example.com/booking/bk_7f3a9c/cancel?t=..., where the token is either a long random value stored hashed on the booking, or a signature over the booking id and an expiry. It should allow exactly one thing (manage this booking), expire after the meeting, and stop working once the booking is cancelled.
- Opening the link shows a page with a button; only the button's
POSTcancels. Many corporate email scanners open the links in a message to check them for malware, so a cancel that happens onGETcancels meetings nobody meant to cancel. - Reschedule opens the normal booking page with the old booking attached. Booking the new time and releasing the old one happen in one transaction, so the invitee never ends up with zero or two meetings.
- Keep tokens out of logs and analytics: strip the query string before sending page views. The auth tokens lesson covers token handling in general.
Reminders and routing forms
Reminders are scheduled jobs created by the outbox: for example 24 hours and 1 hour before the start instant. Each job checks that the booking is still confirmed and at the same time before sending, so a cancel or reschedule never needs to hunt down old jobs. The reminder text formats the time in the recipient's own zone, using the zone names stored on the booking. The notifications center guide covers delivery and preferences.
A routing form comes before the calendar: a few questions ('company size?', 'which product?') whose answers pick the meeting type or the team member. Run the routing rules on the server, so they can use data the invitee should not see (a list of key accounts), and pass the answers into the booking form as prefilled fields.
Check your understanding · Exercise 6
Which choices about the cancel link in the confirmation email are sound? Select all that apply.
Architecture, data model, and API
Put the pieces together. The browser holds very little: a page shell, a small cache of slot lists, and the URL. The server side does the work that needs secrets (calendar tokens) or must be trusted (availability and booking). Each boundary below exists for a reason.
| Part | Where | Responsibility |
|---|---|---|
| Booking page shell | Server rendered, then hydrated | Host name, photo, meeting type, length, and a month skeleton. Cacheable at the CDN for minutes, since it has no per-invitee data. |
| Slots client | Browser | Fetches instants per month with AbortController, caches them for about a minute, prefetches the next month, refetches on focus when stale. |
| Calendar view and time list | Browser components | Group instants by local date in the chosen zone and format them with Intl. Pure functions of instants, zone, and hour format. |
| Booking form | Browser component | Answers, validation, the idempotency key, and the 409 path that keeps answers. |
| Availability service | Server | Runs the pipeline. Reads rules from the database and busy times through the busy cache. Used by both the slots and the booking endpoints. |
| Busy cache | Server (for example Redis) | Busy lists per calendar and month, 30 to 60 second TTL, cleared by provider change notifications. |
| Calendar connectors | Server | Hold OAuth tokens, call Google freeBusy and Graph getSchedule (dropping any event details), create and update events. Tokens never reach the browser. |
| Booking service and outbox worker | Server | Fresh check, atomic insert, then events, video links, emails, and reminders with retries. |
Why the availability service is shared: if the booking endpoint ran different rules from the slots endpoint, the page would offer times that confirm refuses, or confirm would accept times the page never offers. Why connectors are separate: each provider has its own auth refresh, rate limits, and error shapes, and team scheduling multiplies the calls. Why the shell is separate from slots: the shell is the same for every invitee and can come from the CDN in tens of milliseconds, while slots are personal to 'now' and must be computed.
Data model and API
The browser's model is small on purpose. It never sees rules or busy times, only what the host chose to publish and the instants that came out of the pipeline.
type EventTypePublic = {
id: string; hostName: string; title: string;
lengthMinutes: number; location: "google_meet" | "zoom" | "teams" | "phone";
questions: { id: string; label: string; required: boolean }[];
hostTimeZone: string; // shown as "Priya is in India (IST)"
};
type SlotMonth = {
month: string; // "2026-11", as the page asked for it
slots: string[]; // ["2026-11-03T05:30:00Z", ...], sorted
nextAvailable: string | null;
fetchedAt: number; // Date.now() when it arrived
};
type BookingDraft = {
slot: string; timeZone: string; answers: Record<string, string>;
idemKey?: string; // created on first submit, reused on retry
};
On the server, a booking stores the start and end instants, the host and invitee zone names, the provider event id, the status, the idempotency key, and the hashed manage token. The rules store wall times plus a zone name, as the time zone chapter argued.
GET /api/event-types/priya/30min
-> 200 { "id": "et_9", "title": "Intro call", "lengthMinutes": 30, ... }
GET /api/event-types/et_9/slots?from=2026-10-31T00:00Z&to=2026-12-02T00:00Z
-> 200 { "slots": ["2026-11-02T03:30:00Z", ...], "nextAvailable": null }
POST /api/bookings
Idempotency-Key: 6f1c0b9e-4a1d-4c7e-9a52-2b8d1f0e7c11
{ "eventTypeId": "et_9", "start": "2026-11-05T11:00:00Z",
"timeZone": "Europe/Lisbon", "answers": { "name": "Ana", "email": "ana@example.com" } }
-> 201 { "id": "bk_7f3a9c", "start": "...", "end": "...", "status": "confirmed" }
-> 409 { "code": "SLOT_TAKEN", "message": "...", "freshSlots": ["..."] }
The slots request uses UTC bounds with a day of padding, as the booking page chapter explained, and the server caps the range (say 45 days) so one request cannot ask for a year.
Errors use one shape: a stable code for the client to branch on, a human message, and extra fields where they help, like freshSlots. Return 422 for invalid answers, 409 for a taken slot, 429 with Retry-After when rate limited, and 503 when a calendar provider is down. The REST API design lesson covers the conventions.
Check your understanding · Exercise 7
Which parts can safely be cached at a CDN for several minutes? Select all that apply.
Check your understanding · Exercise 8
Why does the booking request send the invitee's timeZone if the slot is already a UTC instant? Select all that apply.
Embedding the booking page on the host's site
Many hosts do not send a link; they put the booking page on their own website, in a 'Book a demo' section or behind a button. The embeddable widget guide covers loader scripts and iframes in general. Here are the parts specific to a booking page.
Decision What should the host paste into their site?
What we pick: Offer the loader script, with the plain link as the fallback. Calendly works the same way: its widget.js script offers inline, popup widget, and popup text embeds, and the embedded page posts events such as calendly.event_scheduled and, for inline embeds, calendly.page_height to the parent window.
The iframe page uses postMessage to talk to its parent. A ResizeObserver on the page's root sends the new height when it changes, and the loader sets the iframe's height. On each step the page also sends a named event (event_type_viewed, date_and_time_selected, event_scheduled with the booking id), which the loader re-emits as a DOM event on the container.
Send ids, not personal data: the host's page can fetch details through the API with its own credentials. Calendly does the same, and its scheduled event carries only the API addresses of the event and the invitee. The loader passes the parent's origin in the iframe URL so the page posts to that origin only, and the loader checks event.origin on every message it receives.
event.origin before acting. The scheduled event carries an id, not the invitee's email.- Framing rules: the public booking route must allow framing by any site (
Content-Security-Policy: frame-ancestors *and noX-Frame-Optionsheader, on that route only). The manage, cancel, and host settings pages sendframe-ancestors 'none', so a hostile page cannot frame them and trick a click. The clickjacking and iframes lesson covers why. - No cookies needed: Safari and Firefox block or partition third-party cookies in iframes, and users can turn them off in Chrome, so the booking flow must work with none. It does, because invitees never sign in.
- Prefill: name, email, and answers can be passed as query parameters to the iframe URL. Show them as editable fields, not hidden ones.
Check your understanding · Exercise 9
Which embed decisions are right? Select all that apply.
Accessibility, privacy, and performance
An accessible date grid and time list
The month calendar is a grid of day buttons; the date picker guide covers its keyboard model in depth (arrow keys move by day and week, Page Up and Page Down by month, one tab stop for the whole grid). Two things are specific to booking.
First, availability must not be shown by colour alone. Available days get a bold number, a dot, and an accessible name that says how many times they have ('Tuesday 3 November, 8 times available'); unavailable days stay in the grid so arrow keys can cross them, but are marked aria-disabled. Second, the time list is a short list of real buttons, each named with the full time and zone ('11:00 GMT'), so a screen reader user never has to look elsewhere for the zone.
Announce what changes without moving focus. A polite live region says '8 times available on Tuesday 3 November' after a day is picked, and 'Times now shown in Los Angeles time, PST' after the zone changes, because the whole list changed under the user's cursor. The 409 message gets focus, since the user must act on it. The ARIA patterns and keyboard navigation lessons cover the building blocks.
Privacy and abuse
- Only free start times leave the server. No titles, no attendees, no busy blocks, and no host email in the public API.
- Free times still hint at busy times. Limit how far ahead and how often anyone can look: cap the range per request, and rate limit the slots endpoint per IP and per host (for example 60 requests a minute per IP, an estimate to tune), so a script cannot map a person's calendar for months.
- Rate limit bookings per IP and per email, and add a challenge after unusual volume, so nobody fills a host's week with fake meetings.
Performance
The largest contentful paint is the host header and the month skeleton, both in the CDN-cached HTML, so a 2.5 second LCP on a mid-range phone is easy. The slots request starts before hydration and, with a warm busy cache, should answer in about 100 to 200 ms (an estimate). Keep the booking page's JavaScript small (under about 100 KB compressed is a reasonable budget), since it loads inside other people's websites. No date library is needed in the browser: Intl does all the formatting.
Check your understanding · Exercise 10
A screen reader user changes the zone from London to Los Angeles. What should happen? Select all that apply.
Running it in the interview
In 45 minutes, spend about 5 on scope, 5 drawing the wireframe and the architecture, then 25 on the three deep parts: availability, time zones, and booking. Keep the last 10 for after-booking, embedding, and follow-ups. Draw the pipeline (rules, overrides, limits, busy, buffers) early: it shows you know slots are computed, not stored.
Say these sentences out loud, because they are what interviewers listen for: 'The host's calendar never leaves the server; the page gets only free instants.' 'Rules are wall time plus an IANA zone, converted per date; bookings are UTC instants.' 'The server re-checks at confirm and a database constraint makes the insert atomic.' 'The retry carries the same idempotency key.'
Think first
Follow-up: 'Make it work for a sales team of 8 where any rep can take the call.' What changes?
Availability becomes the union of the 8 reps' slots, so the busy cache now serves 8 calendars per page view and connector rate limits matter more. The booking step picks the rep at confirm time (priority, then least recently booked), re-checks only that rep's calendar, and falls back to the next free rep if the check fails, before answering 409.
The page itself does not change: it still receives a list of instants. That is a good sign the boundary is in the right place.
Think first
Follow-up: 'A user in Chile says their slots were an hour off after a government changed the clock rules.' What happened and what do you do?
The zone database on the server or in the user's browser was older than the rule change. Instants stored for bookings are still correct; it is the conversion of rules (server) or the display (browser) that used old data.
Fix the server by updating its time zone data and recomputing; slots are computed on demand, so nothing stored needs migrating. For old browsers, show the host's zone and time next to the invitee's on the confirmation screen and in emails, so a mismatch is visible to both people.
Check your understanding · Exercise 11
Which of these would an interviewer count as weak signals? Select all that apply.
Summary
- Slots are computed, never stored: weekly rules in the host's zone, plus overrides, cut by notice and booking window, minus busy times widened by buffers, capped by daily limits.
- Compute on the server. The host's calendar never leaves it; the page receives only free start instants.
- Store rules as wall time plus an IANA zone and convert them per date. Store bookings as UTC instants plus both zone names. The browser only formats, with
Intl. - Group slots by the invitee's local date. Fetch one month padded by a day in UTC, so the request does not depend on the zone, and prefetch the next month.
- Do not rely on holds. Re-check the host's calendars at confirm, insert under a no-overlap constraint, answer 409 with fresh times, and make retries safe with an idempotency key.
- After the commit, an outbox creates the calendar event, the video link, the email with a stable-UID
.ics, and reminders, each with retries. - Embed with a small loader and an iframe that reports height and events by
postMessage, with only the public route frameable.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Google Calendar API: Freebusy: query Busy intervals for a set of calendars, without event details.
- Microsoft Graph: calendar getSchedule Free/busy and availability views for users and rooms.
- RFC 5545: Internet Calendaring and Scheduling (iCalendar) VEVENT, UID, DTSTAMP, DTSTART, and the file format.
- RFC 5546: iCalendar Transport-Independent Interoperability Protocol (iTIP) METHOD:REQUEST and CANCEL, and SEQUENCE for updates.
- Calendly developer docs: embed parent window events The postMessage events an embedded booking page sends.
- MDN: Intl.DateTimeFormat The timeZone and hourCycle options, and resolvedOptions.
- MDN: Temporal Zoned date-time arithmetic and its browser support.
- IANA Time Zone Database The source of zone names and daylight saving rules.
Concepts behind it
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.