Back to BlogIndustry Guides

A Property Viewings Scheduling System Without Double-Booking

Rupak Amin

Founder & Lead Engineer, RAITHub

8 min read

A property viewings system double-books when two appointments land on the same agent, or the same property, at the same time. The fix is not application code that checks for a clash first; two requests can both pass that check at once. It is a PostgreSQL exclusion constraint that makes an overlapping row impossible to commit, then concurrency tests that prove it.

If you would rather have it built and tested for you, see how RAITHub would build this below. First, the data model and the constraint that does the real work.

What exactly double-books in a viewings system?

Two resources clash, not one. An agent cannot be at two viewings at once, and a property (or a specific unit) should not host two unrelated buyers in the same slot. A naive booking flow reads "is this slot free?", sees yes, and inserts, so two buyers booking the same 2pm Saturday viewing both succeed when their requests overlap. This is the same race that costs a booking platform the most, covered more broadly in the short-let booking platform QA plan; here the resource is an agent's calendar rather than a property's nights.

How do you make an overlapping booking impossible in PostgreSQL?

Store each viewing as a time range and add an exclusion constraint. An exclusion constraint is like a unique constraint, but instead of rejecting equal values it rejects values that overlap. Scope it per agent and per property with btree_gist, so the database itself refuses a second row in the same window. Two racing inserts cannot both win: one commits, the other fails.

-- Ranges and the overlap operator need these extensions.
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE viewings (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL,          -- the agency (multi-tenant isolation)
  agent_id    uuid NOT NULL,
  property_id uuid NOT NULL,
  slot        tstzrange NOT NULL,     -- stored in UTC
  status      text NOT NULL DEFAULT 'booked',
  CONSTRAINT valid_slot CHECK (lower(slot) < upper(slot))
);

-- No two active viewings may overlap for the SAME agent.
ALTER TABLE viewings ADD CONSTRAINT agent_no_overlap
  EXCLUDE USING gist (
    agent_id WITH =,
    slot WITH &&
  ) WHERE (status = 'booked');

-- And none may overlap for the SAME property.
ALTER TABLE viewings ADD CONSTRAINT property_no_overlap
  EXCLUDE USING gist (
    property_id WITH =,
    slot WITH &&
  ) WHERE (status = 'booked');

The WHERE (status = 'booked') clause is deliberate: a cancelled viewing should not block the slot, so the constraint ignores it. When the second insert fails, catch the unique-violation error and return "that slot was just taken" rather than a 500. The same database-level discipline stops oversold stock in commerce (oversell prevention inside the order transaction): the rule lives in the database, not in a hope that two requests never arrive together.

How do you handle time zones and travel gaps?

Store every slot in UTC and convert at the edges. A buyer in one zone, an agent in another and a property in a third is normal, and bugs appear around daylight-saving changes, where an hour repeats or disappears. Keep the booking UI working in the property's local zone, persist UTC, and render back in each viewer's zone. If an agent needs travel time between viewings, extend the stored slot to include a buffer, so the exclusion constraint enforces the gap for free instead of you writing more checks.

What edge cases break reschedules and cancellations?

CaseWhat should happen
Reschedule into a taken slotReject with a clear message; the old slot stays until the move succeeds
Cancel, then re-book the same slotAllowed: the cancelled row no longer blocks (partial constraint)
Two buyers request the last slot at onceOne wins; the other sees "just taken", not an error page
Agent off sickBlock the agent's day; existing viewings flagged for reassignment
Daylight-saving transitionSlot length computed in UTC stays correct across the change
No-showStatus change, kept for reporting; slot frees for future bookings

How do you prove two buyers cannot book the same slot?

Test it under concurrency, firing both requests together, because the race is the whole point. A sequential test passes even on broken code. Fire N parallel bookings for one slot and assert exactly one succeeds.

// tests/viewings/no-double-book.spec.ts
import { test, expect } from '@playwright/test'
import { tokenFor, freeSlot } from './helpers'

test('only one of many concurrent bookings for a slot wins', async ({ request }) => {
  const token = await tokenFor('buyer@test')
  const slot = await freeSlot() // same property, same agent, same window

  const attempts = Array.from({ length: 10 }, () =>
    request.post('/api/viewings', {
      headers: { Authorization: 'Bearer ' + token },
      data: slot,
    }),
  )
  const results = await Promise.all(attempts)
  const ok = results.filter((r) => r.status() === 201)
  const taken = results.filter((r) => r.status() === 409)

  expect(ok.length).toBe(1)        // exactly one booking created
  expect(taken.length).toBe(9)     // the rest told the slot is taken, cleanly
})

Run it against a real PostgreSQL database, not a mock, because the exclusion constraint only exists there. Add tests for the reschedule and cancel-then-rebook cases above, and a role test so a buyer cannot book on another agent's behalf (the role pattern is in designing SaaS authorization).

Buy, build or hire?

OptionWhat you getChoose this when
A generic scheduling tool (Calendly-style)Agent availability and a booking link, fastYou only schedule an agent's time and don't track per-property slots
A no-code form plus calendarRequests collected, booked by handLow volume, and a person checks for clashes
A custom viewings modulePer-agent and per-property slots, double-booking impossible, reschedules and reportingViewings are core to your platform and clashes cost you deals
A managed QA team on your buildConcurrency, time-zone and reschedule suites gated in CIYou have the feature but double-booking is not proven on every build

How long does it take to build yourself?

A working viewings module with the exclusion constraint, time-zone handling and a concurrency test is roughly 4 to 8 hours for an engineer fluent in PostgreSQL ranges and btree_gist, and a few days once reschedules, reminders and reporting are included. The main risk of doing it yourself is enforcing the rule in application code instead of the database: it works in every demo and fails the first busy Saturday, which is exactly when it matters.

Why RAITHub for this

RAITHub built PropDesk, property-management SaaS with four roles, scheduled automation jobs and 1,024 tests, and tests the same class of race in its short-let booking QA work. Scheduling, roles and the data model sit inside the wider build covered in PropTech software development and the SaaS development service. See the real-estate industry page for the broader PropTech context.

When you don't need us

  • You only book an agent's time. A generic scheduling tool handles that without a custom build.
  • Volume is low and someone checks the calendar. A shared calendar and a form may be enough.
  • Your procurement requires a SOC 2 or ISO 27001 vendor. RAITHub is not certified, though it builds the controls your auditor tests.

How RAITHub would build this

  • Scope: a viewings data model with per-agent and per-property exclusion constraints, UTC storage with per-zone rendering, reschedule and cancellation flows, reminders and no-show handling, scoped to each agency.
  • Timeline: part of a 4 to 6-week fixed-scope first release; heavy calendar, reminder and reporting work sits in the 6 to 12-week range, in phases.
  • What you receive: the concurrency, time-zone and reschedule suites gated in CI, runbooks, the product on accounts you own, IP assigned to you and an NDA as standard.
  • Ways to buy it: a fixed-scope build, a dedicated monthly team, or a QA plan if you only need the scheduling test suite.
  • Next step: a free 15-minute technical audit, then a fixed written quote.

See the QA as a Service page, or book the free 15-minute audit.

Documentation checked on 10 October 2026.

Frequently asked questions

How do you stop a property viewing from being double-booked?

Store each viewing as a time range and add a PostgreSQL exclusion constraint on the agent and on the property, so the database rejects any overlapping row. Two requests racing for the same slot cannot both commit: one wins and the other is told the slot was just taken.

Why not just check for a clash in application code?

Because two requests can both read "the slot is free" before either inserts, and both then succeed. A database-level exclusion constraint is evaluated at commit time, so only one overlapping row can ever exist, no matter how the requests interleave.

What is a btree_gist exclusion constraint?

It is a constraint that rejects rows whose values overlap, rather than rows that are equal. The btree_gist extension lets you combine an equality check (same agent) with an overlap check (same time range) in one constraint, which is exactly what a no-double-booking rule needs.

How should time zones be handled for viewings?

Store every slot in UTC and convert at the edges: book in the property's local zone, persist UTC, and render in each viewer's zone. Computing slot length in UTC keeps it correct across daylight-saving changes, where an hour can repeat or disappear.

How do you test that the no-double-booking rule works?

Fire many booking requests for the same slot at once and assert exactly one succeeds while the rest are cleanly rejected. Run it against a real PostgreSQL database, because the exclusion constraint only exists there, and add reschedule and cancel-then-rebook cases.

property viewings schedulingdouble booking preventionproptech saasexclusion constraintappointment schedulingpostgresql

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.