Skip to main content

Mutation

import { api } from "@/convex/_generated/api";
import { useMutation } from "convex/react";

const joinWaitingList = useMutation(api.events.joinWaitingList);
Location: convex/events.ts:111-189

Description

Adds a user to an event’s waiting list. If tickets are immediately available, the user receives an instant offer with a 30-minute expiration. Otherwise, they are placed in the queue and will receive an offer when a ticket becomes available. This mutation includes rate limiting to prevent abuse and automatic offer management.

Rate Limiting

Rate Limit: 3 joins per 30 minutes per userThe rate limit is applied per userId across all events using a fixed window algorithm.
Configuration (convex/events.ts:18-24):
const rateLimiter = new RateLimiter(components.rateLimiter, {
  queueJoin: {
    kind: "fixed window",
    rate: 3,              // 3 joins allowed
    period: 30 * MINUTE,  // in 30 minutes
  },
});
When rate limit is exceeded, an error is thrown with the retry-after time:
You've joined the waiting list too many times. Please wait X minutes before trying again.

Parameters

eventId
Id<'events'>
required
The ID of the event to join the waiting list for
userId
string
required
The unique identifier of the user joining the waiting list

Response

success
boolean
required
Whether the operation completed successfully
status
enum
required
The resulting status of the waiting list entry
  • offered - Tickets were available, user has an active offer
  • waiting - No tickets available, user is in queue
message
string
required
Human-readable message describing the result
  • Offered: "Ticket offer created - you have 30 minutes to purchase"
  • Waiting: "Added to waiting list - you'll be notified when a ticket becomes available"

Behavior

Immediate Offer (Tickets Available)

When tickets are available:
  1. Creates waiting list entry with status: "offered"
  2. Sets offerExpiresAt to current time + 30 minutes
  3. Schedules automatic expiration job
  4. Returns success with status: "offered"
Code (convex/events.ts:149-166):
if (available) {
  const waitingListId = await ctx.db.insert("waitingList", {
    eventId,
    userId,
    status: WAITING_LIST_STATUS.OFFERED,
    offerExpiresAt: now + DURATIONS.TICKET_OFFER, // 30 minutes
  });

  await ctx.scheduler.runAfter(
    DURATIONS.TICKET_OFFER,
    internal.waiting_list.expireOffer,
    { waitingListId, eventId }
  );
}

Queue Placement (No Tickets)

When no tickets are available:
  1. Creates waiting list entry with status: "waiting"
  2. User will receive offer when queue is processed
  3. Returns success with status: "waiting"
Code (convex/events.ts:167-174):
else {
  await ctx.db.insert("waitingList", {
    eventId,
    userId,
    status: WAITING_LIST_STATUS.WAITING,
  });
}

Validations

Rate Limit Check

Verifies the user hasn’t exceeded 3 joins in 30 minutes:
const status = await rateLimiter.limit(ctx, "queueJoin", { key: userId });
if (!status.ok) {
  throw new ConvexError(
    `You've joined the waiting list too many times. Please wait ${Math.ceil(
      status.retryAfter / (60 * 1000)
    )} minutes before trying again.`
  );
}

Duplicate Entry Check

Prevents users from joining the same event multiple times:
const existingEntry = await ctx.db
  .query("waitingList")
  .withIndex("by_user_event", (q) =>
    q.eq("userId", userId).eq("eventId", eventId)
  )
  .filter((q) => q.neq(q.field("status"), WAITING_LIST_STATUS.EXPIRED))
  .first();

if (existingEntry) {
  throw new Error("Already in waiting list for this event");
}

Event Existence Check

Ensures the event exists:
const event = await ctx.db.get(eventId);
if (!event) throw new Error("Event not found");

Examples

Join Waiting List (React)

import { api } from "@/convex/_generated/api";
import { useMutation } from "convex/react";
import { Id } from "@/convex/_generated/dataModel";

function JoinWaitingListButton({ eventId }: { eventId: Id<"events"> }) {
  const joinWaitingList = useMutation(api.events.joinWaitingList);
  const [loading, setLoading] = useState(false);

  const handleJoin = async () => {
    setLoading(true);
    try {
      const result = await joinWaitingList({
        eventId,
        userId: "user_123",
      });

      if (result.status === "offered") {
        alert("You have 30 minutes to purchase your ticket!");
        // Redirect to checkout
      } else {
        alert("You're in the queue! We'll notify you when a ticket is available.");
      }
    } catch (error) {
      if (error.message.includes("too many times")) {
        alert("Rate limit exceeded. Please try again later.");
      } else {
        alert(error.message);
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleJoin} disabled={loading}>
      {loading ? "Joining..." : "Join Waiting List"}
    </button>
  );
}

Response Examples

Immediate Offer:
{
  "success": true,
  "status": "offered",
  "message": "Ticket offer created - you have 30 minutes to purchase"
}
Queue Placement:
{
  "success": true,
  "status": "waiting",
  "message": "Added to waiting list - you'll be notified when a ticket becomes available"
}

Error Responses

Rate Limit Exceeded:
{
  "error": "You've joined the waiting list too many times. Please wait 15 minutes before trying again."
}
Already in Waiting List:
{
  "error": "Already in waiting list for this event"
}
Event Not Found:
{
  "error": "Event not found"
}

Build docs developers (and LLMs) love