Booking and Appointment Forms: Timezones, Slots and Confirmation
How to build a booking form that gets timezones, DST, slot generation and confirmations right: UTC storage, Postgres exclusion constraints and valid ICS
A client once shipped a booking form that quietly lost them about a fifth of their consultations. Nobody noticed for six weeks. The bug: slots were generated in the server’s timezone (UTC, on a Frankfurt box), rendered without any timezone label, and their Australian customers were booking 9am appointments that landed at 8pm Melbourne time. The customers showed up. Nobody else did.
Appointment scheduling looks like a solved problem until you build one. Then you discover that “next Tuesday at 2pm” is not a point in time, that DST removes an hour twice a year in ways your slot loop does not expect, and that two people clicking the same slot 40ms apart will both get a confirmation email unless you did something about it at the database level.
This guide is written after cleaning up several of these. It covers the data model, timezone handling that survives DST, the slot UI, concurrency, and the confirmation flow that actually gets people to turn up.
- Store bookings as UTC instants, but store availability rules as wall-clock times plus an IANA timezone identifier. These are different data types and conflating them is the root cause of most DST bugs.
- Generate slots on the server in the business’s timezone, send them to the client as ISO 8601 instants, and format them in the visitor’s zone with
Intl.DateTimeFormat. Never do date arithmetic in the browser. - Prevent double bookings with a database constraint, not application logic. In Postgres that is an
EXCLUDE USING gistconstraint on atstzrange, which costs one line and eliminates the entire race condition class. - Always label times with the timezone the visitor is reading them in, and expose a timezone switcher with the detected zone preselected. Unlabelled times are the single most expensive UI omission in booking flows.
- Attach a valid ICS file with a stable UID and increment
SEQUENCEon every reschedule, otherwise calendar clients create duplicates instead of updating the original event.
Two kinds of time, and why mixing them breaks everything
There are two temporal concepts in any booking system and they are not interchangeable.
A booking is an instant. It happened, or will happen, at a single absolute moment that every observer on Earth agrees on. Store it as UTC (timestamptz in Postgres, never timestamp). Store the customer’s IANA zone alongside it in a separate column so you can re-render their confirmation later without guessing.
Availability is a wall-clock rule. “Dr Chen works 09:00 to 17:00, Monday to Thursday, Europe/Lisbon.” That rule is not a set of instants. It becomes instants only when you project it onto a specific date in a specific timezone, and the projection changes on DST boundaries. If you cached “09:00 Lisbon = 09:00 UTC” in October, your March slots are an hour wrong.
So: availability rules hold startlocal time, endlocal time, weekday, and timezone text. Bookings hold during tstzrange. The slot generator is the function that turns the first into the second. It runs per request or per short-lived cache, never baked into a migration.

Timezone handling that survives DST
Three failure modes cause almost every incident in this area.
The non-existent hour
On the spring transition, local time jumps. In Europe/Lisbon on the last Sunday in March, 01:00 becomes 02:00, so 01:30 does not exist. If your generator loops in 30 minute steps over wall-clock strings and constructs a date object from each, most languages will silently normalise 01:30 to 02:30 and publish a duplicate slot. PHP does this. So does new Date() in some engines. Detect it by round-tripping:
function slotForLocalTime(string $date, string $time, string $tz): ?DateTimeImmutable {
$zone = new DateTimeZone($tz);
$dt = new DateTimeImmutable("$date $time", $zone);
// If PHP had to move the time, this local wall clock does not exist (DST gap).
if ($dt->format('H:i') !== $time) {
return null;
}
return $dt->setTimezone(new DateTimeZone('UTC'));
}
The repeated hour
On the autumn transition, an hour happens twice. 01:30 exists at UTC+1 and again at UTC+0. Constructors pick the first occurrence, which is usually what you want, but if you offer 24 hour availability you will silently drop a bookable hour. Most businesses do not care. Hospitals, hosting support desks and anything with an on-call rota do. Decide explicitly rather than by accident.
Offsets stored instead of zones
Never store +01:00 as a user’s timezone. Store Europe/Lisbon. Offsets are a property of an instant in a zone, not of a person. A booking made in January for a July date, stored with a January offset, is an hour off, and this bug survives every test you write in winter.
On the client, detection is one line and it has been reliable in every browser since roughly 2018:
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; // "Australia/Melbourne"
// Slots arrive from the API as UTC instants: ["2026-02-10T14:00:00Z", ...]
const fmt = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
timeZone: zone,
timeZoneName: 'short', // "AEDT" - show it, always
});
const label = fmt.format(new Date(slot.start)); // "01:00 AEDT"
One catch worth knowing: grouping slots by day must also happen in the visitor’s zone. A 23:30 UTC slot belongs to a different calendar day in Melbourne than in London. Group with Intl.DateTimeFormat(..., { dateStyle: 'full', timeZone: zone }) as the key, not by slicing the ISO string.
The Temporal API makes all of this considerably cleaner. Firefox shipped it first and the other engines followed. In 2026 we still ship the polyfill on production booking flows because the audience is not all on evergreen browsers, and the polyfill is not free in bundle terms. If your slot logic lives on the server, you may not need Temporal on the client at all. Format and display, nothing more.
Designing a slot grid people can book from
The generator needs more inputs than opening hours. Every real deployment ends up with:
- Duration and granularity. A 45 minute appointment offered on a 15 minute grid produces slots at 09:00, 09:15, 09:30. That triples your slot count and fragments the day into unbookable gaps. Align to the duration unless the client insists otherwise.
- Buffers before and after, separately. Travel time before, notes time after.
- Minimum notice. “No bookings within 4 hours.” Compute against
nowin UTC, not against the local date. - Booking horizon. 60 days is a sane default. Infinite horizons generate infinite slots and someone will book March 2031.
- Exceptions: holidays, one-off blocks, and existing bookings pulled from the calendar.
The pattern that consistently works: date picker on the left showing which days have availability, slot list on the right, timezone label above the list with a select to change it. Show a maximum of about 12 slots per day and put the rest behind “show more”. A wall of 32 identical time buttons is a decision-paralysis machine.
Mark up slots as a real radio group so keyboard and screen reader users can move through them with arrows:
<fieldset class="slot-grid">
<legend>Tuesday 10 February, times shown in AEDT</legend>
<label><input type="radio" name="slot" value="2026-02-10T14:00:00Z"> 01:00</label>
<label><input type="radio" name="slot" value="2026-02-10T14:45:00Z"> 01:45</label>
</fieldset>
The value posted back is the UTC instant. The label is the local rendering. Never post the rendered string, because you will end up parsing “01:00” on the server and guessing what it meant.
Keep the form itself to the minimum: name, email, and one field about the purpose of the meeting. Phone number only if someone will actually call. Each extra field on a scheduling form costs completions the same way it does on any other form, and the same discipline we apply to signup forms applies here.
Stopping double bookings properly
The “check then insert” pattern is a race condition wearing a suit. Two requests both read “slot free”, both insert, both succeed. On a low-traffic site you might go a year before it bites. On a launch day, it bites in the first hour.
Push the invariant into Postgres:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
staff_id bigint NOT NULL,
during tstzrange NOT NULL,
customer_id bigint NOT NULL,
status text NOT NULL DEFAULT 'confirmed',
created_at timestamptz NOT NULL DEFAULT now(),
-- One staff member cannot have two overlapping live bookings. Ever.
EXCLUDE USING gist (
staff_id WITH =,
during WITH &&
) WHERE (status <> 'cancelled')
);
A conflicting insert raises 23P01 and you catch it and return “that slot just went, here are three others”. That error path needs a designed screen, not a 500. It will fire in production.
Two more pieces:
- Soft holds. If your flow has a payment step, insert a
status = 'held'row with a 10 minute expiry as soon as the slot is chosen, and sweep expired holds with a cron job. The exclusion constraint covers held rows too, which is exactly what you want. - Idempotency keys. Generate a UUID when the form renders, send it with the submission, and make it unique in the table. Double-clicked submit buttons and mobile network retries stop producing duplicate bookings and duplicate charges.
If you are on a static site with no backend, you can still run the capture layer through a hosted endpoint like WebForms and handle the availability check in a small serverless function. What you cannot do is pretend a static site can arbitrate concurrency. Something has to own the write.
The confirmation is where bookings are won or lost
The confirmation page and email do more work than the booking form. Get them wrong and you eat the no-show rate.
What the confirmation must contain, in this order:
- The date and time in the customer’s timezone, labelled, and the business’s timezone underneath in smaller text if they differ. Write it out fully: “Tuesday 10 February 2026, 1:00 PM AEDT (2:00 AM GMT)”. Ambiguity here is what causes missed appointments.
- Who they are meeting and how (video link, address, phone).
- An “Add to calendar” block: an
.icsattachment plus a Google Calendar link, because a meaningful share of users are in a webmail client where the attachment is awkward. - Reschedule and cancel links containing a signed, single-purpose token. Not a login. Not a booking ID that increments.
The ICS file is where most implementations get sloppy. Minimum viable and correct:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SemiColonWeb//Booking 1.0//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
UID:[email protected]
SEQUENCE:0
DTSTAMP:20260114T101500Z
DTSTART:20260210T140000Z
DTEND:20260210T144500Z
SUMMARY:Discovery call with Acme
LOCATION:https://meet.acme.com/4f9c2ab1
ORGANIZER;CN=Acme:mailto:[email protected]
ATTENDEE;CN=Jane Doe;RSVP=TRUE:mailto:[email protected]
END:VEVENT
END:VCALENDAR
Three rules that matter. Lines must end with CRLF, not LF, or Outlook rejects the file outright. The UID must be stable for the life of the booking, so store it. When the customer reschedules, resend the same UID with SEQUENCE:1 and the new times; that updates the existing calendar entry instead of creating a second one. Using UTC Z times avoids shipping a full VTIMEZONE block, which is the pragmatic choice for one-off appointments. Recurring events are a different conversation and genuinely need VTIMEZONE.
Reminders, reschedules and the follow-up
Two reminders is the sweet spot: one 24 hours out, one 1 to 2 hours out. The 24 hour reminder should carry the reschedule link prominently, because a reschedule is worth vastly more than a no-show. The short-notice reminder should carry the join link and nothing else.
Send reminders in the customer’s local timezone, and check that your queue is not firing a “24 hours before” email at 3am their time because the job scheduler runs on UTC. Add a quiet-hours clamp: if the computed send time falls between 22:00 and 07:00 local, move it to 08:00.
After the appointment, one short follow-up. Two questions maximum, one of them optional. The same completion mechanics we wrote about in feedback widgets people actually finish apply directly here, and the response rate on a post-appointment survey is far higher than on a generic site-wide one because the context is fresh.
Things that will bite you in production
- Tzdata drift. Governments change DST rules with weeks of notice. Your production containers need current tzdata; a stale base image will produce wrong offsets for affected zones. Rebuild rather than assume.
- Corporate laptops with locked timezones. Detection returns the machine zone, which is sometimes wrong. This is precisely why the timezone selector must be visible and editable, not buried.
- Calendar sync latency. If you pull busy times from Google or Microsoft, cache them for no more than 60 seconds, and re-verify at the moment of writing. Users book from a page that has been open for 20 minutes.
- Bots. Booking endpoints get hammered. A honeypot field plus a per-IP rate limit stops the vast majority. Hold off on a captcha until you have evidence you need one; it costs real conversions.
- Timezone in the admin view. Staff should see their own zone, with the customer’s zone in a tooltip. Showing UTC in the back office is a support ticket generator.
Frequently Asked Questions
Should slots be generated on the server or in the browser?
Server, always. The browser cannot be trusted with availability, buffers, existing bookings or minimum notice, and any logic you duplicate client-side will drift. The browser’s only job is to format the UTC instants your API returns into the visitor’s local time and render them.
Do I need the Temporal API to handle this correctly?
No. Intl.DateTimeFormat with a timeZone option handles all display formatting and it works everywhere. Temporal is genuinely better for arithmetic and for zoned wall-clock reasoning, so if your slot generation runs client-side you will want it, but the cleaner architecture keeps that arithmetic on the server anyway.
How do I stop two people booking the same slot?
Use a database-level constraint. In Postgres, an EXCLUDE USING gist constraint on a tstzrange plus staff ID rejects any overlapping insert atomically. Catch the error code and show the user alternative times rather than a generic failure page.
Should I ask for a timezone or detect it?
Detect it and let them change it. Prefill the select with Intl.DateTimeFormat().resolvedOptions().timeZone, keep it visible near the slot list, and persist the choice in the booking record. Detection is right for most consumers and wrong often enough for corporate users that hiding the control causes real problems.
Why does rescheduling create duplicate calendar entries?
Because the updated ICS used a new UID. Calendar clients match events by UID, so keep the original UID for the life of the booking and increment SEQUENCE on each change. Include METHOD:REQUEST and the same organiser address, and clients will update the existing entry in place.
If you are building one of these from scratch, start with the data model and the exclusion constraint. Get those two right on day one and the rest is UI work you can iterate on. Get them wrong and every feature you add afterwards sits on top of a race condition and an ambiguous timestamp. That is a rewrite waiting for a busy week to reveal itself.
The next thing to fix, once bookings are landing correctly, is the confirmation email. Open it on a phone, in Gmail and in Outlook, and check whether a stranger could tell within two seconds when and where they are supposed to be. That single check finds more real problems than another round of slot-picker polish.

