Skip to content

One booking, end to end

2 min read · main part only; folded sections are optional

After this you can answer: What happens, box by box, from the click on Book to the confirmation email?

flowchart TB
B[Browser] -->|"POST /trpc/bookings.create"| ALB[ALB]
ALB -->|"port 4000"| API[API task]
API -->|"session, limits"| V[(Valkey)]
API -->|"booking + outbox row"| PG[(Postgres)]
API -.->|"after the answer"| SNS[SNS topic]
SNS --> Q[Jobs queue]
Q --> L["Worker Lambda<br/>sends the email"]

The solid arrows happen during the request. The dashed arrow happens after the user already has the answer.

  1. Find the address. The browser looks up minitickets.theversehub.in. Cloudflare DNS answers with the load balancer’s name.
  2. Enter through the ALB. The browser sends POST /trpc/bookings.create over HTTPS. A listener rule sends every /trpc/* path to the API’s target group, and the ALB picks a healthy API task.
  3. Who is calling? The API reads the sid cookie and loads the session from Valkey. No session means 401.
  4. Is it too much? A rate limit allows a burst of 10 bookings, then one per second. A seat counter in Valkey rejects a sold-out event early with 409.
  5. Book it. One Postgres transaction locks the event row, takes the seats, inserts the booking and an outbox row (the “send the email” message), then commits. The browser gets its answer.
  6. After the answer. A relay loop in the API publishes the outbox row to an SNS topic. SNS copies it into the jobs queue, and a Lambda function sends the email with SES.

The browser side is one hook, apps/web/src/features/bookings/hooks/use-book-ticket.ts → useBookTicket. The server side is apps/api/src/services/bookings.ts → bookTicket, which calls the locked transaction in apps/api/src/queries/bookings.ts → createBookingTx. SNS also copies the event into a second queue, where another Lambda makes the QR ticket.

The map replays this exact path, one connection at a time, with the rule that allows each step.

Play “One booking, end to end” on the map →

CloudWatch → Log groups → /minitickets/dev/api: one booking writes a request line with its correlationId. The worker’s log lines carry the same id.

SymptomCauseWhere to look
401 on BookNo session: logged out, or Valkey lost its dataThe sid cookie; ElastiCache events
409 “Not enough seats left”Someone else took the last seats firstWorking as designed
Booking works, no emailThe outbox row cannot be published, or SES refusesAPI log, then the jobs DLQ
Why not send the email inside the request?

SES could be slow or down. Then every booking would be slow or fail, although the seats are already safe. The outbox row commits with the booking, so the email is never lost and never blocks the answer.

What if the API crashes right after the commit?

The booking and its outbox row are both saved. When an API task runs again, its relay finds the unsent row and publishes it. The user may get the email a little later, never not at all.

What if the user double-clicks Book?

Both requests carry the same idempotency key. The second one finds the first booking and returns it; no seats are taken twice. Part 3 explains how.

What is the trade-off of doing the email later?

The user sees “Booked!” before the email exists. If sending keeps failing, the message ends in a dead-letter queue and an alarm fires; a person must act. That is the price of a fast, safe booking.

Advanced: Every step and its rule

The same flow, generated from the book’s data, with the exact connection each step uses:

A logged-in user clicks Book. We follow the request from the browser to the database, and the confirmation email after the response. (only when cdnEnabled = false)

  1. Browser → Public DNS. The browser looks up minitickets.theversehub.in. Cloudflare answers with a CNAME to the ALB's DNS name.If it breaks: The CNAME still points at an old ALB: the browser gets a DNS error. Look: dig +short CNAME minitickets.theversehub.in versus pulumi stack output albDnsName.
  2. Browser → Application Load Balancer. The browser sends POST /trpc/bookings.create over HTTPS. The ALB security group allows inbound TCP 443.If it breaks: No inbound 443 rule: the connection times out. Look: EC2 → Security Groups → minitickets-dev-alb-sg → Inbound rules (443 from allowedCidrs).
  3. Application Load Balancer → HTTPS listener. The :443 listener ends TLS with the ACM certificate.
  4. HTTPS listener → API target group. Listener rule 100 matches /trpc/* and forwards to the api target group.If it breaks: Without the rule, the default action sends the call to web, which answers 404. Look: Listeners and rules → HTTPS:443 → Rules.
  5. API target group → API ECS service. The ALB picks a healthy api task and connects on port 4000, allowed by api-sg's inbound rule from alb-sg.If it breaks: No healthy target: the ALB answers 503. Look: EC2 → Target groups → the api group → Targets.
  6. API ECS service → API app. Fastify gives the request a correlation id, and tRPC runs bookings.create (a protectedProcedure).
  7. API app → Sessions. createContext loads the session from the sid cookie. No session means 401.If it breaks: Valkey lost its data: every user is logged out. Look: ElastiCache → minitickets-dev-valkey → Events; the api log.
  8. API app → Rate limiter. The booking bucket gives a token (burst 10, then 1 per second).If it breaks: The bucket is empty: 429 Too Many Requests. Look: The api log: 429s per client IP; TRUST_PROXY_HOPS.
  9. API app → Inventory gate. The inventory gate reserves the seats in Valkey. Sold out would be a 409 right here.
  10. API app → RDS Postgres. One transaction: lock the event row (SELECT … FOR UPDATE), insert the booking and its outbox row, commit. The api answers the browser.If it breaks: The db security group drops the packets: connection timeout, the booking fails. Look: EC2 → Security Groups → minitickets-dev-db-sg; ECS → the task → Network.
  11. Transactional outbox → Booking-events SNS topic. After the response, the outbox relay publishes the booking-confirmed event to SNS with the task role's sns:Publish.If it breaks: The task role may not publish: the row stays pending and is retried. Look: The api log group, and IAM → minitickets-dev-api-task-role → send-jobs.
  12. Booking-events SNS topic → Jobs SQS queue. SNS copies the event into the jobs queue (and the tickets queue), allowed by each queue's resource policy.If it breaks: The queue policy does not allow this topic: nothing arrives. Look: SQS → the queue → Access policy; SNS → the subscription's delivery status.
  13. Jobs SQS queue → Worker Lambda. The event source mapping polls the queue and calls the worker Lambda with a batch.If it breaks: The account's Lambda concurrency is used up: the worker is throttled. Look: Lambda → Monitor → Throttles; aws lambda get-account-settings.
  14. Worker Lambda → Processed-jobs table. GetItem on jobKey: this email was not sent yet.
  15. Worker Lambda → SES email identities. SES sends the confirmation from no-reply@minitickets.theversehub.in, then the worker records the job as done.If it breaks: In the sandbox SES refuses an unverified recipient. Look: SES → Account dashboard (sandbox), SES → Identities.

Answer these without scrolling up. If one feels hard, reread that section.

  1. Which listener rule sends the booking to the API?
  2. What two things does the API check in Valkey before it touches Postgres?
  3. What does the one Postgres transaction write?
  4. Why is the email sent after the answer, and how is it never lost?
  5. What does the user see if Valkey loses all its data?

Redraw it from memory: the booking path: browser, ALB, API task, Valkey, Postgres, then SNS, the jobs queue and the worker Lambda

Try this: on the map, play the booking flow and open 'What if it breaks?' on the Valkey step

Read this book online: https://book.theversehub.in/