SevyDevy

Guide · System design

Meeting Scheduler LLD: Overlaps, Rooms and Clean Classes

28 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • A meeting scheduler LLD (low-level design) is the set of classes and methods that book rooms and people for time slots, so no room ever holds two meetings at once.
  • Treat each meeting as a half-open interval, a time range that includes its start but not its end. Two meetings clash only when each starts before the other ends.
  • Keep each room's bookings in a Java TreeMap, a map that stays sorted by start time. One lookup, of the latest booking that starts before the new meeting ends, then tells you if it clashes.
  • Make the clash check and the save one step that only one request at a time can run, per room and for the attendees. With more than one server, a PostgreSQL exclusion constraint (a table rule that rejects overlapping rows) is the final judge.
  • Beginners can stop after the room-choice section. Engineers then get tested races, including a naive attendee check that put one person in two rooms, a PostgreSQL rule with expected output, and the rooms-needed sweep.

What goes wrong when a booking app checks for clashes the wrong way?

Two teams end up at the same door, or a free slot gets refused, because the code compares meeting times with the wrong rule. Getting that rule right is the heart of a meeting scheduler LLD.

Picture a common setup. A co-working company runs a booking app for its meeting rooms: three spaces, 40 rooms and about 1,200 bookings a week. The setup is made up, but the bugs are real. In one week the front desk logs three complaints.

  • A sales team booked Room B from 10:30 to 11:30. Later, a design team booked Room B from 10:00 to 11:00. The app accepted both.
  • A team of nine got Room A, which seats four. The app never compared the head count with the room size.
  • On another day, a founder could not book Room B from 11:00 to 12:00, straight after a 10:00 to 11:00 meeting. The app called it a clash.

The first complaint is the dangerous one, because two groups now hold one room. The first version only asked whether a new meeting starts inside a booked one. The design team's 10:00 start is not inside 10:30 to 11:30, so it slipped through.

The third complaint is the opposite mistake. The same check counted the end minute as inside the meeting, so 11:00 looked busy and back-to-back meetings looked like clashes. One wrong rule caused both bugs.

This guide rebuilds the core classes in order. First how to model time, then how to find clashes fast and pick a room, and then how to stay correct when many people book at once.

What is a meeting scheduler LLD?

A meeting scheduler LLD is the low-level design of a booking app: the classes, the data each one holds and the methods that book rooms and people without clashes. It sits one level below system design, which draws servers and databases as boxes.

LLD stands for low-level design. In an LLD interview you write the classes and the key methods, often in Java. You also explain why each rule lives in the class where you put it.

For the co-working app, the job has four parts. Model a meeting's time correctly. Find clashes fast. Choose a room that fits. Stay correct when many people book at the same moment.

One picture helps for the whole guide. Think of a village with three shared tractors and a sarpanch, the elected village head, who keeps the booking roster. Here is how the roster maps to the code.

  • The roster is the Scheduler: the one place every request goes through.
  • One page per tractor is one Room object, holding only that room's bookings.
  • Each entry is a range of days, written in date order, like a booking sorted by start time.
  • A tractor returned at noon is free for a farmer who starts at noon. Nobody counts noon twice, which is the half-open rule.

Where the picture breaks: the sarpanch handles one farmer at a time, so two people never write on one page at once. A server handles many requests at the same moment. That gap is why the second half of this guide is about locks.

How do you check if two meetings overlap?

Two meetings overlap when the first starts before the second ends and the second starts before the first ends. In code, that is a.start < b.end and b.start < a.end.

This rule only works if you define a meeting's end carefully. Use a half-open interval: a 10:00 to 11:00 meeting covers 10:00 and every moment up to 11:00, but not 11:00 itself.

This is not a private trick. The iCalendar standard, RFC 5545, is the internet standard for calendar data. It says in section 3.6.1 that an event's end time is its non-inclusive end. PostgreSQL range constructors with two arguments build the same shape by default, written [start, end).

Here is the check as a picture. Start at the new meeting and answer the two questions in order. Notice that a single No anywhere means there is no clash.

Does a new meeting clash with a booked one?

Two questions settle every overlap case. Both answers must be Yes for a clash; one No means the room is free.

Diagram connections:

  • New meeting request to Starts before booked end?
  • Starts before booked end? to Ends after booked start? (Yes)
  • Starts before booked end? to No clash: room is free (No)
  • Ends after booked start? to Clash: refuse or move (Yes)
  • Ends after booked start? to No clash: room is free (No)
  • New meeting request: Room B, 10:30 to 11:30
  • Starts before booked end?: Booked: 10:00 to 11:00. 10:30 is before 11:00
  • Ends after booked start?: 11:30 is after 10:00
  • Clash: refuse or move: Both answers were Yes
  • No clash: room is free: At least one answer was No

The naive first try is five separate if statements: starts inside, ends inside, covers, sits inside and exact match. It is easy to miss one, and the first version of the app checked only one of them. The two-question rule covers every case, as the table shows against the booked Room B meeting from 10:00 to 11:00.

New requests checked against a Room B booking from 10:00 to 11:00
New requestStarts before 11:00?Ends after 10:00?Result
09:00 to 10:00YesNo, it ends exactly at 10:00No clash, back to back
10:30 to 11:30YesYesClash, the first complaint
10:15 to 10:45YesYesClash, sits inside
09:30 to 11:30YesYesClash, covers it fully
11:00 to 12:00No, it starts exactly at 11:00YesNo clash, the founder's slot

Time zones can break this check too. Store every start and end as a Java Instant. Its docs call it "An instantaneous point on the time-line", one moment that reads the same in every zone. Convert to India time only when you show it, so a comparison never mixes zones.

Which classes does a meeting scheduler need?

A meeting scheduler needs a small set of classes, each owning one job: Booking, Room, AttendeeCalendar, RoomPicker, Scheduler and Notifier. A person is just an id string here.

  • Booking: one meeting. It holds an id, a start, an end, the organiser and the attendees. It refuses any end that is not after the start.
  • Room: a name, a capacity and its own sorted bookings. Only a Room decides whether a slot inside it is free.
  • AttendeeCalendar: each person's busy times. It answers whether everyone can come, and holds them while a room is found.
  • RoomPicker: decides which rooms to try, and in what order. Swapping it changes the booking policy without touching Room.
  • Scheduler: the front door. It holds the attendees, asks the picker for rooms, tries each room and returns the result.
  • Notifier: sends invites after a booking succeeds, so no lock is held while email goes out.

Look at where the arrows point in the class diagram below. The Scheduler talks to every class that decides a booking, but Room talks only to its own bookings. That keeps the clash rule in one place, where it is easy to test.

Meeting scheduler classes

The Scheduler coordinates. Only Room decides if its own time is free, and only AttendeeCalendar decides if people are free. The caller sends invites after book returns.

Diagram connections:

  • Booking screen to Scheduler (book(booking))
  • Scheduler to AttendeeCalendar (tryHold(booking))
  • Scheduler to RoomPicker (Which rooms, in order?)
  • Scheduler to Room (tryBook(booking))
  • Room to Booking (Stores)
  • Booking screen to Notifier (After book returns)
  • Booking screen: Where members ask for a room
  • Scheduler: Coordinates one booking request
  • AttendeeCalendar: Busy times for each person
  • RoomPicker: Orders rooms to try, smallest fit first
  • Room: Capacity plus bookings sorted by start
  • Booking: Id, start, end, organiser, attendees
  • Notifier: Sends invites after success

Booking fits a Java record, a short class whose fields are set once in the constructor and never reassigned. Records became a standard Java feature in JDK 16, through JEP 395, a JDK Enhancement Proposal. The code in this guide compiles and runs on Java 17 and 21, tested on 17.0.20.1 and 21.0.12.1 on 19 October 2026.

java
import java.time.Instant;
import java.util.List;

public record Booking(String id, Instant start, Instant end,
                      String organiser, List<String> attendees) {
    public Booking {
        if (!end.isAfter(start)) {
            throw new IllegalArgumentException("end must be after start");
        }
        attendees = List.copyOf(attendees);   // a fixed copy nobody can change later
    }

    public int seatsNeeded() {
        return attendees.size();              // callers must list the organiser here too
    }
}

The block after public Booking is a compact constructor: it runs before the fields are set. It refuses a meeting that ends at or before its start. It also replaces the attendee list with a fixed copy.

That copy matters. A record's fields cannot be reassigned, but a list inside one can still change if the caller keeps a reference to it. With List.copyOf, the caller's later changes do not reach the booking, and adding to booking.attendees() throws an UnsupportedOperationException. We ran both checks on Java 21.

How do you find a clash fast without checking every meeting?

Keep each room's bookings sorted by start time, then compare the new meeting with just one neighbour. A Java TreeMap does this, so the work grows very slowly as bookings pile up.

The naive way is a list per room, checked from top to bottom. That works for a dozen bookings. But Room B may hold a year of bookings, and every new request would scan them all.

A TreeMap is a map that keeps its keys in sorted order. Its documentation guarantees log(n) time for containsKey, get, put and remove, where n is the number of entries.

The method we need, lowerEntry, returns the entry with the greatest key strictly less than the key you give it. It is not in that guaranteed list. In the JDK 21 source it walks one path down the same balanced tree. In our test, lowerEntry among 1,000 bookings saved in time order took at most 17 comparisons, and about 10 on average.

Pause and predict: to check a new meeting from 10:30 to 11:30, which single booking do you need to look at? Think about it before reading on.

Here is the answer. Bookings already in a room never overlap, so sorting them by start also sorts them by end. Among the bookings that start before 11:30, the latest one also ends last. If that one ends at or before 10:30, nothing in the room clashes.

The sarpanch does the same with his roster. To check days 5 to 7, he does not read the whole page. He finds the last entry that starts before day 7 and checks whether it runs past day 5.

java
import java.time.Instant;
import java.util.Map;
import java.util.TreeMap;

public final class Room {
    private final String name;
    private final int capacity;
    private final TreeMap<Instant, Booking> byStart = new TreeMap<>();

    public Room(String name, int capacity) {
        this.name = name;
        this.capacity = capacity;
    }

    public String name() { return name; }
    public int capacity() { return capacity; }

    public synchronized boolean tryBook(Booking b) {
        if (b.seatsNeeded() > capacity) {
            return false;                     // too many people for this room
        }
        Map.Entry<Instant, Booking> prev = byStart.lowerEntry(b.end());
        if (prev != null && prev.getValue().end().isAfter(b.start())) {
            return false;                     // the latest earlier booking runs into ours
        }
        byStart.put(b.start(), b);
        return true;
    }

    public synchronized boolean cancel(Booking b) {
        return byStart.remove(b.start(), b);
    }
}

Two comparisons carry the design. lowerEntry(b.end()) is strict, so a booking starting exactly at the new end is ignored. isAfter is also strict, so a booking ending exactly at the new start is fine. Together they are the half-open rule.

Using the start time as the map key is safe, because two bookings in one room can never share a start time without overlapping. Room also checks the head count itself. Then the nine-person team cannot land in Room A, whatever the RoomPicker does.

You can check it yourself. Save Booking.java, Room.java and the RoomDemo.java below in an empty folder, then run javac *.java and then java RoomDemo. It replays the complaints on Room B, which seats eight.

java
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.stream.IntStream;

public class RoomDemo {
    static final ZoneId INDIA = ZoneId.of("Asia/Kolkata");

    static Booking meeting(String from, String to, int people) {
        Instant start = LocalDateTime.parse("2026-10-20T" + from).atZone(INDIA).toInstant();
        Instant end = LocalDateTime.parse("2026-10-20T" + to).atZone(INDIA).toInstant();
        List<String> names = IntStream.rangeClosed(1, people).mapToObj(i -> "p" + i).toList();
        return new Booking(from + "-" + to, start, end, "p1", names);
    }

    static void tryIt(Room room, String from, String to, int people) {
        boolean ok = room.tryBook(meeting(from, to, people));
        System.out.println(from + " to " + to + ", " + people + " people: "
                + (ok ? "booked" : "refused"));
    }

    public static void main(String[] args) {
        Room room = new Room("Room B", 8);
        tryIt(room, "10:00", "11:00", 4);
        tryIt(room, "10:30", "11:30", 4);
        tryIt(room, "11:00", "12:00", 4);
        tryIt(room, "09:00", "10:00", 4);
        tryIt(room, "10:15", "10:45", 4);
        tryIt(room, "14:00", "15:00", 9);
    }
}

You should see exactly these six lines: 10:00 to 11:00, 4 people: booked. 10:30 to 11:30, 4 people: refused. 11:00 to 12:00, 4 people: booked. 09:00 to 10:00, 4 people: booked. 10:15 to 10:45, 4 people: refused. 14:00 to 15:00, 9 people: refused.

What just happened? The two back-to-back requests at 11:00 and 09:00 were booked, the overlapping 10:30 and 10:15 requests were refused, and the team of nine was refused on size. We also compared tryBook with a full scan on 300,000 random requests and found no difference.

How do you pick the right room for a meeting?

Pick the smallest free room that seats everyone. This best-fit rule keeps big rooms free for big groups, and it fixes the complaint about nine people in a four-seat room.

Say the space has Room A with 4 seats, Room B with 8 and Room C with 12. A team of six should get Room B. If Room B is busy, Room C is the fallback. Room A is never tried, because it is too small.

Put this rule behind an interface called RoomPicker. Its one method takes the rooms and the booking, and returns the rooms to try, in order. This is the Strategy pattern: one interface, many swappable policies.

java
import java.util.Comparator;
import java.util.List;

public interface RoomPicker {
    List<Room> order(List<Room> rooms, Booking b);
}

final class SmallestFitPicker implements RoomPicker {
    @Override
    public List<Room> order(List<Room> rooms, Booking b) {
        return rooms.stream()
                .filter(r -> r.capacity() >= b.seatsNeeded())
                .sorted(Comparator.comparingInt(Room::capacity))
                .toList();
    }
}

Other policies slot in the same way. A same-floor picker tries rooms on the organiser's floor first. An equipment picker keeps only rooms with a screen when the request asks for one.

The Scheduler ties the pieces together. It first holds every attendee, then tries the rooms in the picker's order. AttendeeCalendar, shown in the attendee section, marks people busy in one locked step.

java
import java.util.List;
import java.util.Optional;

public final class Scheduler {
    private final List<Room> rooms;
    private final RoomPicker picker;
    private final AttendeeCalendar people;

    public Scheduler(List<Room> rooms, RoomPicker picker, AttendeeCalendar people) {
        this.rooms = List.copyOf(rooms);
        this.picker = picker;
        this.people = people;
    }

    public Optional<Room> book(Booking b) {
        if (!people.tryHold(b)) {
            return Optional.empty();          // someone is busy: offer another time
        }
        for (Room room : picker.order(rooms, b)) {
            if (room.tryBook(b)) {
                return Optional.of(room);
            }
        }
        people.release(b);                    // no room fits: give the people back
        return Optional.empty();
    }
}

The loop is where clashes get handled gracefully. If tryBook says no, the Scheduler moves to the next room the picker suggested. It never reads a room's bookings itself, so the overlap rule stays inside Room.

The people step runs before the rooms, because there is no point holding Room C for a meeting the organiser cannot attend. If every room fails, release gives the people back. The screen can then offer the next free slot instead of a bare error.

How do you stop two people booking the same room at the same moment?

Make the clash check and the save one step that only one thread can run per room at a time. A thread is one line of work that a server runs alongside others. Inside one Java process, the synchronized methods on Room do this. Java lets only one thread at a time run them on the same Room. Across several servers, a database exclusion constraint does it.

Inside one app: lock one room at a time

Say two members tap Book for Room B at 10:30 in the same second. Both threads check, both see the room free, and both save.

This is a check-then-act race: the world changes between the check and the save. The TreeMap documentation also warns that the class is not synchronized. If threads share it and any one of them changes its structure, you must add the locking yourself.

We tested this on OpenJDK 21.0.12.1. Two threads booked overlapping slots in a fresh room, released at the same instant, many times over. The table shows how often both were accepted.

Two threads, overlapping slots, one fresh room per round (OpenJDK 21.0.12.1, four runs each)
Version of RoomRounds per runBoth acceptedWhat it shows
As written, synchronized5,0000 in every runThe lock makes check and save one step
synchronized removed5,0000 to 8 across our runsThe race is real but rare
synchronized removed, 1 ms pause before saving5,0004,970 to 5,000Any slow step between check and save opens the gap
synchronized kept, same 1 ms pause5,0000 in every runThe lock still holds

The middle row is the trap. A race that fails a few times in 5,000 tries passes every quick test. The 1 ms pause stands in for a slow database call, and then the race fires almost every time.

Notice what gets locked. Each Room locks only itself, so the room step for Room A never waits on Room C. One big lock on the whole Scheduler would make every room step queue in one line. The attendee step is different. In this sketch AttendeeCalendar is one shared lock, so every booking passes through it briefly. In our test, a Room A booking with nobody in common waited while a Room C request held it. A busy building could lock per person instead, always in sorted id order, so two requests never wait on each other forever. We did not test that version.

Why the attendee check needs the same care

People have the same race. A tempting version calls allFree to check the attendees and, only after a room is booked, addBusy to mark them. Each call is locked, but the pair is not one step.

Two threads booked the same person at the same time into two different rooms. That version put the person in both rooms in 8 to 477 of 2,000 rounds across four runs. With a 1 ms pause after the check, it did so in at least 1,990.

The fix is the tryHold method you saw in the Scheduler. It checks and marks every attendee in one locked call, and release undoes it when no room is free. With the guide's code, the same test gave 0 of 2,000 in every run, with or without the pause.

The trade-off is small. For the moment between tryHold and release, another request for the same people sees them as busy. That is a short false no, not a double booking.

Follow one booking through the steps below. Each box names a step, and the detail line says which class talks to which.

One booking request, step by step

People are held before any room is tried, and each lock covers one step only. A 23P01 refusal on the room constraint sends the request to the next room.

Diagram connections:

  • Member asks for a room to Hold all attendees
  • Hold all attendees to Get rooms to try (All free)
  • Hold all attendees to Suggest another time (Someone busy)
  • Get rooms to try to Lock room, check, save
  • Lock room, check, save to Insert booking row (Room free)
  • Lock room, check, save to Release attendees (Every room taken)
  • Release attendees to Suggest another time
  • Insert booking row to Send invites (Row accepted)
  • Insert booking row to Lock room, check, save (23P01 on room: next room)
  • Member asks for a room: Booking screen → Scheduler
  • Hold all attendees: Scheduler → AttendeeCalendar
  • Get rooms to try: Scheduler → RoomPicker
  • Lock room, check, save: Scheduler → Room
  • Insert booking row: Server → PostgreSQL, the final judge
  • Send invites: Caller → Notifier, after book returns
  • Release attendees: Scheduler → AttendeeCalendar
  • Suggest another time: Scheduler → Booking screen

Across servers: let PostgreSQL be the final judge

A Java lock only protects one running copy of the app. With two servers, each one has its own Room objects in memory. Server 1's TreeMap never sees a booking that server 2 just made.

So once you run more than one server, the database table is the source of truth. The rule that really stops a double booking has to live in the database. The in-memory check becomes a fast first filter, and it can be wrong both ways. It misses bookings made on other servers, and it may still hold one cancelled elsewhere. So treat its no as a hint: confirm with the database, or reload that room's day from the table first.

PostgreSQL has exactly that rule, an exclusion constraint. Its range types documentation shows the meeting-room case, using the btree_gist extension so that the room id can be compared with equals inside the same index. An index is a structure the database keeps to find rows fast. GiST is a PostgreSQL index type that can check ranges for overlap.

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE booking (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id   int         NOT NULL,
    starts_at timestamptz NOT NULL,
    ends_at   timestamptz NOT NULL,
    status    text        NOT NULL DEFAULT 'CONFIRMED',
    CHECK (ends_at > starts_at),
    EXCLUDE USING gist (
        room_id WITH =,
        (tstzrange(starts_at, ends_at)) WITH &&
    ) WHERE (status = 'CONFIRMED')
);

Read the EXCLUDE line as: no two confirmed rows may have the same room_id and overlapping time ranges. tstzrange(starts_at, ends_at) builds a half-open range, the same rule as the Java code. The WHERE part means a cancelled booking stops blocking the slot, without deleting the row.

You can check it on PostgreSQL 16 by running the four inserts below in psql, PostgreSQL's command-line client, after the CREATE TABLE above.

sql
INSERT INTO booking (room_id, starts_at, ends_at)
VALUES (2, '2026-10-20 10:00+05:30', '2026-10-20 11:00+05:30');  -- accepted

INSERT INTO booking (room_id, starts_at, ends_at)
VALUES (2, '2026-10-20 10:30+05:30', '2026-10-20 11:30+05:30');  -- refused: overlaps

INSERT INTO booking (room_id, starts_at, ends_at)
VALUES (2, '2026-10-20 11:00+05:30', '2026-10-20 12:00+05:30');  -- accepted: back to back

INSERT INTO booking (room_id, starts_at, ends_at)
VALUES (3, '2026-10-20 10:30+05:30', '2026-10-20 11:30+05:30');  -- accepted: other room

Expect INSERT 0 1, then an error, then INSERT 0 1 twice. The error reads: conflicting key value violates exclusion constraint "booking_room_id_tstzrange_excl". Its error code is 23P01, which PostgreSQL names exclusion_violation.

In your app, catch that error by its code, 23P01, not by its message text. When it names the booking constraint, cancel the booking you just added to that Room in memory. Then try the next room, as if tryBook had returned false.

A transaction is a group of statements that succeed or fail together. Read Committed is PostgreSQL's default rule for what one transaction can see of another's work. We raced two sessions on PostgreSQL 16.15 at that level. The first session inserted a row and kept its transaction open for 2 seconds. The second, sent 0.5 seconds later, waited about 1.5 seconds for the first to commit, then failed with 23P01.

When the first session rolled back instead, the waiting insert succeeded. So in our test the second insert did not decide early: it waited for the first transaction, then followed what that transaction did.

People need the same backing once you run several servers, because tryHold also lives in one server's memory. One answer is an attendee_busy table, with one row per person per meeting and the same kind of constraint on person_id.

We tested that too. Insert the room row and the attendee rows in one transaction, and a clash for any one person rolls back the whole booking, room row included. That person clash also returns 23P01, but the error names attendee_busy_person_id_tstzrange_excl instead. So check the constraint name: a person clash means suggest another time, not try the next room.

If you skip CREATE EXTENSION btree_gist, the CREATE TABLE fails. The error says type integer has no default operator class for access method gist. The PostgreSQL 16 docs mark btree_gist as trusted, so a user with CREATE privilege on the database can install it.

How do you check that every attendee is free?

Keep a list of busy meetings for each person, and run the same two-question overlap rule against each attendee's list. If anyone has a clash, offer other times instead of booking.

java
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class AttendeeCalendar {
    private final Map<String, List<Booking>> busy = new HashMap<>();

    // Check every attendee and mark them busy in one locked step.
    public synchronized boolean tryHold(Booking b) {
        for (String person : b.attendees()) {
            for (Booking other : busy.getOrDefault(person, List.of())) {
                if (other.start().isBefore(b.end()) && b.start().isBefore(other.end())) {
                    return false;             // same two-question overlap rule
                }
            }
        }
        for (String person : b.attendees()) {
            busy.computeIfAbsent(person, k -> new ArrayList<>()).add(b);
        }
        return true;
    }

    public synchronized void release(Booking b) {
        for (String person : b.attendees()) {
            List<Booking> list = busy.get(person);
            if (list != null) {
                list.remove(b);
            }
        }
    }
}

Notice that this class scans each person's list instead of using the TreeMap trick. The trick needs entries that never overlap. This code always blocks people clashes, so here they never do, and a per-person TreeMap would also work. But if your product only warns about people clashes, or imports busy times from another calendar, a person's entries can overlap.

We tested what goes wrong. With busy times of 09:00 to 12:00 and 10:00 to 10:30, the one-neighbour check finds only the 10:00 entry. It then calls 11:00 to 11:30 free, missing the 09:00 to 12:00 meeting. This sketch keeps every meeting in the list; a real one would drop past meetings or group them by day, so each scan stays short.

The iCalendar standard has a name for this data: free/busy time. Its FBTYPE parameter marks a period as FREE, BUSY, BUSY-UNAVAILABLE or BUSY-TENTATIVE, and BUSY is the default. A design can copy that idea and treat a tentative meeting as soft busy.

Finding a common free slot is a merge problem. Collect every attendee's busy times for the day, sort them by start and merge any that overlap. The gaps left between merged blocks are the times when everyone is free.

  1. Collect busy times

    The organiser is busy 09:00 to 10:00 and 13:00 to 14:00. One attendee is busy 09:30 to 11:00.

  2. Sort by start

    The list becomes 09:00 to 10:00, then 09:30 to 11:00, then 13:00 to 14:00.

  3. Merge overlaps

    09:30 starts before 10:00 ends, so the first two merge into 09:00 to 11:00. The 13:00 block stays separate.

  4. Read the gaps

    Between 09:00 and 18:00, both are free from 11:00 to 13:00 and from 14:00 to 18:00.

  5. Match a room

    For a one-hour meeting, try 11:00 to 12:00 first, and run the room loop for that slot.

If your product only warns about people clashes, add a flag that skips the person check when the organiser books anyway. A room never gets that flag, because a room cannot be in two meetings.

How many meeting rooms does a day of meetings need?

If any room can hold any meeting, the number of rooms needed equals the largest number of meetings running at the same moment. You find it by sweeping through all starts and ends in time order, counting up and down.

This is the question to ask before opening a new space, and a classic interview problem. The half-open rule applies here too, so an end and a start at the same time do not count together.

Take four meetings: 09:00 to 10:00, 09:30 to 11:00, 10:00 to 10:30 and 10:30 to 11:15. Write each start as plus one and each end as minus one. Sort them by time, and put ends before starts when the times are equal.

  • 09:00 start: 1 room in use.
  • 09:30 start: 2 rooms in use.
  • 10:00 end, then 10:00 start: back to 2.
  • 10:30 end, then 10:30 start: still 2.
  • 11:00 and 11:15 ends: down to 0.

The answer is two rooms. If you processed the 10:00 start before the 10:00 end, the count would touch 3 and you would plan a room you do not need. The code below prints rooms needed: 2.

java
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class MinRooms {
    record Event(LocalTime at, int change) {}   // +1 for a start, -1 for an end

    static int minRooms(List<LocalTime[]> meetings) {
        List<Event> events = new ArrayList<>();
        for (LocalTime[] m : meetings) {
            events.add(new Event(m[0], +1));
            events.add(new Event(m[1], -1));
        }
        // sort by time; at equal times the -1 (end) comes before the +1 (start)
        events.sort(Comparator.comparing(Event::at).thenComparingInt(Event::change));
        int inUse = 0, peak = 0;
        for (Event e : events) {
            inUse += e.change();
            peak = Math.max(peak, inUse);
        }
        return peak;
    }

    public static void main(String[] args) {
        List<LocalTime[]> day = List.of(
                new LocalTime[] {LocalTime.parse("09:00"), LocalTime.parse("10:00")},
                new LocalTime[] {LocalTime.parse("09:30"), LocalTime.parse("11:00")},
                new LocalTime[] {LocalTime.parse("10:00"), LocalTime.parse("10:30")},
                new LocalTime[] {LocalTime.parse("10:30"), LocalTime.parse("11:15")});
        System.out.println("rooms needed: " + minRooms(day));
    }
}

Sorting costs n log n time for n meetings, and the sweep itself is a single pass. A min-heap (a structure that always hands back its smallest value first) of end times gives the same answer. We checked both against a minute-by-minute count on 20,000 random days with no mismatch.

Where the count stops being the answer

The peak count assumes every room fits every meeting. Once rooms have sizes, the peak is only the least you need. Smallest fit is also a sensible default, not a guarantee, because requests arrive one at a time.

Here is a case we checked by trying every assignment. Room A seats 4 and Room B seats 8. The requests arrive as 4 people from 10:00 to 12:00, then 3 people from 11:00 to 13:00, then 6 people from 12:00 to 13:00.

Smallest fit puts the first team in Room A and the second in Room B, so the team of six finds nothing. Putting the first team in Room B instead books all three. A real product can offer a swap when a big team is refused.

How should a meeting scheduler handle time zones and weekly meetings?

Store a single meeting's start and end as Instants, but store a repeating series as a local time plus a time zone id. Expand each date in that zone, then convert each one to an Instant and run the room check.

India's clocks have not changed since 1945, according to the time zone data in Java 21, but attendees elsewhere change theirs. RFC 5545 has a form for this case: a local date and time plus a time zone reference, called TZID.

Take a weekly 09:00 meeting set by a team in New York. New York clocks go back one hour on 1 November 2026, according to the tz data version 2026b shipped with our Java 21 build. This code expands two weeks of the series.

java
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class WeeklyDemo {
    public static void main(String[] args) {
        ZoneId newYork = ZoneId.of("America/New_York");
        ZoneId india = ZoneId.of("Asia/Kolkata");
        LocalDate first = LocalDate.parse("2026-10-26");
        for (int week = 0; week < 2; week++) {
            ZonedDateTime local = ZonedDateTime.of(first.plusWeeks(week),
                    LocalTime.parse("09:00"), newYork);
            System.out.println(local.toInstant() + " = "
                    + local.withZoneSameInstant(india).toLocalTime() + " in India");
        }
    }
}

It prints 2026-10-26T13:00:00Z = 18:30 in India, then 2026-11-02T14:00:00Z = 19:30 in India. If you had stored one Instant and added seven days, the second meeting would land at 13:00 UTC, which is 08:00 in New York, an hour early. UTC is the world's reference clock.

How do you explain a meeting scheduler LLD in a system design interview?

Start with the overlap rule and the half-open interval, then build the classes around it. Show the clash check in code, and finish with what happens when two bookings arrive at once.

  1. Ask about scope

    Rooms only, or people too? One building or many? Repeating meetings? Say which you will cover and which you will skip.

  2. Define time

    State that every meeting is a half-open interval stored as an Instant. Give the overlap rule: each starts before the other ends.

  3. Draw the classes

    Booking, Room, AttendeeCalendar, RoomPicker, Scheduler and Notifier. Say that only Room decides if its own time is free.

  4. Write tryBook

    Use a TreeMap keyed by start and one lowerEntry lookup. Walk through a back-to-back case out loud.

  5. Choose rooms

    Explain smallest fit behind a RoomPicker interface, and name one other policy you could swap in.

  6. Handle the races

    Lock one room, not the whole scheduler, and hold attendees in one step. Add a database exclusion constraint for more than one server.

  7. Offer extensions

    Mention free-slot search, repeating meetings, cancellations and the rooms-needed sweep, if time allows.

Questions people ask

What is the formula to check if two time intervals overlap?
Two intervals overlap when a.start < b.end and b.start < a.end. This works when both are half-open, meaning each includes its start and excludes its end. With this rule, back-to-back meetings such as 10:00 to 11:00 and 11:00 to 12:00 do not count as a clash.
Why use a TreeMap instead of a list for room bookings?
A TreeMap keeps bookings sorted by start time, so one lowerEntry call finds the only booking that could clash. A list needs a scan of every booking for each request. The TreeMap docs guarantee log(n) time for get, put and remove, and lowerEntry walks the same tree. The trick needs bookings that never overlap, which is true for a room.
Should the meeting scheduler lock the whole system or one room?
Lock one room at a time, so the room step for one room never waits on another. A single global lock is simpler to write but makes every request queue behind every other one. Attendees need their own step: check and mark them in one locked call. This guide's sketch uses one short shared lock for that, so every booking passes through it.
Is a Java lock enough to prevent double bookings?
Only if a single copy of your app runs. With two or more servers, each has its own memory and its own locks, and none sees the others' bookings. Add a database rule, such as a PostgreSQL exclusion constraint on room and time range. The database then rejects an overlapping row from any server with error code 23P01.
Which design patterns fit a meeting scheduler LLD?
Strategy fits room choice, through a RoomPicker interface with policies such as smallest fit or same floor. Observer, where listeners are told when something happens, fits notifications, so invites go out after a booking without Room knowing about email. Not a pattern, but a record fits Booking, because its fields are set once and never reassigned.
How do you find the minimum number of meeting rooms needed?
Sort every start and end time, then sweep through them, adding one at each start and removing one at each end. The highest count you reach is the answer. When an end and a start share a time, process the end first. This assumes any room fits any meeting; with room sizes, the count is only a lower bound.
How should a meeting scheduler handle time zones?
Store each single meeting as absolute points in time, such as Java Instants, and convert to the viewer's zone only on screen. Store a repeating series as a local time plus a zone id, and expand each date in that zone. Otherwise a weekly meeting shifts by an hour when the organiser's clocks change.

Sources and further reading

Practise this with Lala

Practise