Waitlist System Low-Level Design

Waitlist System — Low-Level Design

A waitlist system queues users for access to a limited resource: early access to a product, sold-out event tickets, or oversubscribed appointment slots. It manages queue position, invite generation, and expiry of unused spots. This design is asked at Airbnb, Coinbase, and early-stage product companies where controlled rollouts are common.

Core Data Model

WaitlistEntry
  id              BIGSERIAL PK
  waitlist_id     BIGINT NOT NULL     -- which waitlist
  user_id         BIGINT              -- null if anonymous
  email           TEXT NOT NULL
  position        INT                 -- queue position, assigned on join
  status          TEXT DEFAULT 'waiting'  -- waiting, invited, accepted, expired, removed
  referral_code   TEXT UNIQUE         -- user's shareable code
  referred_by     TEXT                -- referral code used on signup
  priority_score  INT DEFAULT 0       -- higher = moves up the queue
  joined_at       TIMESTAMPTZ NOT NULL
  invited_at      TIMESTAMPTZ
  accepted_at     TIMESTAMPTZ
  invite_expires_at TIMESTAMPTZ

Waitlist
  id              BIGSERIAL PK
  name            TEXT NOT NULL       -- 'Beta Access', 'Event #42'
  capacity        INT NOT NULL        -- total spots available
  spots_released  INT DEFAULT 0       -- how many invites sent so far
  invite_window_hours INT DEFAULT 48  -- hours to accept before invite expires
  created_at      TIMESTAMPTZ

Joining the Waitlist

def join_waitlist(waitlist_id, email, referral_code=None):
    # Idempotency: same email joins only once
    existing = db.get_by(WaitlistEntry, waitlist_id=waitlist_id, email=email)
    if existing:
        return {'position': existing.position, 'status': existing.status}

    referred_by = referral_code  # store who referred them

    # Assign position atomically
    with db.transaction():
        position = db.execute("""
            SELECT COALESCE(MAX(position), 0) + 1
            FROM WaitlistEntry WHERE waitlist_id=%(wid)s
        """, {'wid': waitlist_id}).scalar()

        entry = WaitlistEntry.create(
            waitlist_id=waitlist_id,
            email=email,
            position=position,
            referral_code=generate_referral_code(),
            referred_by=referred_by,
            joined_at=now(),
        )

    # Boost referrer's priority
    if referred_by:
        db.execute("""
            UPDATE WaitlistEntry
            SET priority_score = priority_score + 5
            WHERE referral_code=%(code)s AND waitlist_id=%(wid)s
        """, {'code': referred_by, 'wid': waitlist_id})

    return {'position': position, 'referral_code': entry.referral_code}

Sending Invites

def release_invites(waitlist_id, count):
    """Invite the next `count` eligible users from the waitlist."""
    with db.transaction():
        # Lock the waitlist to prevent concurrent releases
        waitlist = db.execute("""
            SELECT * FROM Waitlist WHERE id=%(id)s FOR UPDATE
        """, {'id': waitlist_id}).first()

        if waitlist.spots_released + count > waitlist.capacity:
            count = waitlist.capacity - waitlist.spots_released

        if count <= 0:
            return 0

        # Select next batch: sort by priority_score DESC, then position ASC
        entries = db.execute("""
            UPDATE WaitlistEntry
            SET status='invited',
                invited_at=NOW(),
                invite_expires_at=NOW() + INTERVAL '%(hours)s hours'
            WHERE id IN (
                SELECT id FROM WaitlistEntry
                WHERE waitlist_id=%(wid)s AND status='waiting'
                ORDER BY priority_score DESC, position ASC
                LIMIT %(count)s
            )
            RETURNING email, id
        """, {'wid': waitlist_id, 'count': count,
              'hours': waitlist.invite_window_hours})

        db.execute("""
            UPDATE Waitlist SET spots_released=spots_released+%(count)s
            WHERE id=%(id)s
        """, {'count': len(entries), 'id': waitlist_id})

    for entry in entries:
        send_invite_email(entry.email, entry.id)

    return len(entries)

Accepting an Invite

def accept_invite(invite_token):
    entry = db.get_by(WaitlistEntry, invite_token=invite_token)
    if not entry or entry.status != 'invited':
        raise InvalidInvite()
    if entry.invite_expires_at < now():
        # Expire this invite and re-release the spot
        db.execute("UPDATE WaitlistEntry SET status='expired' WHERE id=%(id)s",
                   {'id': entry.id})
        release_invites(entry.waitlist_id, 1)  # give spot to next person
        raise InviteExpired()

    db.execute("""
        UPDATE WaitlistEntry SET status='accepted', accepted_at=NOW()
        WHERE id=%(id)s AND status='invited'
    """, {'id': entry.id})

    # Provision access
    grant_access(entry.email, entry.waitlist_id)
    return {'access_granted': True}

Queue Position Display

def get_waitlist_position(waitlist_id, email):
    entry = db.get_by(WaitlistEntry, waitlist_id=waitlist_id, email=email)
    if not entry or entry.status != 'waiting':
        return None

    # Count people ahead in the queue (accounting for priority)
    ahead = db.execute("""
        SELECT COUNT(*) FROM WaitlistEntry
        WHERE waitlist_id=%(wid)s
          AND status='waiting'
          AND (priority_score > %(prio)s
               OR (priority_score = %(prio)s AND position < %(pos)s))
    """, {'wid': waitlist_id, 'prio': entry.priority_score,
          'pos': entry.position}).scalar()

    return {
        'position': ahead + 1,
        'referral_code': entry.referral_code,
        'referrals_made': count_referrals(entry.referral_code),
    }

Key Interview Points

  • Position vs. rank: Store the immutable join position but display the effective rank (accounting for referral boosts). These are two different numbers — confusing them causes user-facing display bugs.
  • Lock at invite release time: SELECT … FOR UPDATE on the Waitlist row prevents two concurrent release jobs from sending more invites than the capacity allows.
  • Expired invite recirculation: When an invite expires, immediately release a new invite to maintain throughput. Don’t wait for the next scheduled release job.
  • Referral fraud: Cap the priority boost per referrer (e.g., max +50 points) and require referees to complete signup before the boost counts, to prevent abuse via fake referrals.

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you assign queue positions without race conditions?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a database transaction with SELECT MAX(position)+1 inside the same transaction that does the INSERT. Because the INSERT is protected by a table-level or row-level lock, two concurrent joins will get sequential positions rather than both getting position N. Alternatively: use a SERIAL or BIGSERIAL column for position — the sequence generator is atomic by design. Or use Redis INCR on a counter key (atomic single-threaded operation) to get the next position, then store it with the entry. Never SELECT MAX then INSERT outside a transaction — this is a classic TOCTOU race condition.”}},{“@type”:”Question”,”name”:”How does a referral boost work in a waitlist queue?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Assign each user a unique referral code on join. When a new user joins using that code, increment the referrer’s priority_score. The queue ordering is: ORDER BY priority_score DESC, position ASC. A higher priority_score moves the user ahead of users who joined earlier but have no referrals. Cap the maximum boost (e.g., +50 points, or ~10 successful referrals worth of benefit) to prevent gaming. Require the referred user to complete signup before the boost counts, preventing fake referral abuse.”}},{“@type”:”Question”,”name”:”How do you re-release a spot when an invite expires?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When a user accepts an invite, check invite_expires_at <= NOW() before granting access. If expired: set status=expired and immediately call release_invites(waitlist_id, 1) to send the next invite. Don’t wait for the next scheduled release job — the spot should go to the next person promptly. For batch expiry (many invites expire at once), run a job every 5 minutes: find all entries where status=invited AND invite_expires_at <= NOW(), set them to expired, and release one spot per expired entry.”}},{“@type”:”Question”,”name”:”How do you show a user their queue position accurately?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Count the number of users ahead of them in the effective queue: WHERE status=’waiting’ AND (priority_score > user.priority_score OR (priority_score = user.priority_score AND position < user.position)). The result + 1 is their effective position. This accounts for referral boosts: a user who joined as position 500 but has priority_score=50 may have an effective position of 120. Display effective position, not join position. For large queues, this query can be slow — cache per-user position with TTL=5 min and invalidate when someone ahead is invited or when the user gains a referral.”}},{“@type”:”Question”,”name”:”How do you prevent someone from joining the same waitlist multiple times?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a UNIQUE constraint on (waitlist_id, email) and handle the conflict with ON CONFLICT DO NOTHING (for silent deduplication) or ON CONFLICT DO UPDATE (to return the existing entry). For logged-in users: also enforce UNIQUE on (waitlist_id, user_id). Check both email and user_id to catch users who register with a different email using the same account. For anonymous signups where email deduplication is the only option: normalize the email (lowercase, trim) before the uniqueness check — "User@Gmail.Com" and "user@gmail.com" are the same address.”}}]}

Waitlist and controlled rollout system design is discussed in Airbnb system design interview questions.

Waitlist and early access system design is covered in Coinbase system design interview preparation.

Waitlist and invite-based signup system design is discussed in LinkedIn system design interview guide.

Scroll to Top