Serverless: the big picture
After this you can answer: Which AWS service would you pick for a queue, a fan-out, an event router, a workflow, an API front door or a compute job, and why is MiniTickets built the way it is?
Picture it
Section titled “Picture it”Where it sits
Section titled “Where it sits”One box per AWS service. Solid lines exist in code; dashed lines and dashed boxes are designed, not built.
flowchart TB api["API service on ECS"] -->|"publishes booking event - task role sns:Publish"| sns["SNS booking-events"] sns -->|"copies to 2 queues - queue policies, sqs:SendMessage"| sqs["SQS jobs + tickets"] sqs -->|"event source mappings - roles sqs:ReceiveMessage"| lambda["Lambda worker + tickets"] lambda -->|"email - ses:SendEmail"| ses["SES"] lambda -->|"job records, tickets - dynamodb:PutItem"| ddb["DynamoDB processed-jobs + tickets"] lambda -->|"QR PNG - s3:PutObject tickets/*"| s3["S3 ticket assets"] api -->|"reads my tickets - dynamodb:Query"| ddb api -->|"presigns URLs, copies posters - s3:GetObject, s3:PutObject"| s3 api -.->|"booking saga - states:StartExecution"| sfn["Step Functions (designed, not built)"] gw["API Gateway check-in (designed, not built)"] -.->|"conditional admit - via check-in Lambda"| ddb sns -.->|"seat-change hint - via queue and Lambda"| appsync["AppSync (designed, not built)"] sns -.->|"reminder schedule - via queue and Lambda"| sched["EventBridge Scheduler (designed, not built)"] sched -.->|"due reminder - sqs:SendMessage"| sqs class api,lambda app class sns,sqs,ddb,s3,ses aws class sfn,gw,appsync,sched service class sfn,gw,appsync,sched designedLegend: app = our code; AWS = built managed service; highlighted and dashed = designed, not built, each on its own page. This diagram has more boxes than usual because it is the whole serverless map.
Play the booking flow in the map → · Open the booking-events topic →
How it works
Section titled “How it works”Follow one booking through every built serverless piece.
- The browser calls
bookings.create. The API locks the event row, writes the booking and an outbox row in one Postgres transaction, and answers. Nothing serverless has happened yet; the booking is already safe. - The outbox relay in the API process publishes the row to SNS (
minitickets-dev-booking-events) and marks it sent. If SNS is down, the row stays pending and is retried from 1 to 60 seconds. - SNS copies the message to both subscribed queues,
minitickets-dev-jobsand the tickets queue, with raw delivery. Each queue now holds its own copy and its own retry state. - The worker’s event source mapping polls the jobs queue, takes up to 10 messages, and invokes the worker Lambda. The worker sends one email through SES per new booking and records the job in DynamoDB.
- The tickets mapping takes 1 message at a time. The tickets Lambda reserves each ticket in DynamoDB, draws a signed QR code, writes the PNG to S3, and completes the record.
- Later the customer opens “My tickets”. The API queries DynamoDB for their tickets and returns presigned S3 URLs. The browser loads the QR images straight from S3.
sequenceDiagram participant B as Browser participant A as API and Postgres box rgb(237,233,254) Serverless participant N as SNS participant Q as SQS queues participant L as Lambdas end participant D as DynamoDB, S3, SES B->>A: bookings.create A->>A: Booking + outbox row, one transaction A-->>B: Booked A->>N: Relay publishes outbox row N->>Q: Copy to jobs and tickets queues Q->>L: Event source mappings invoke L->>D: Email via SES, ticket in DynamoDB, PNG in S3 L-->>Q: Report failed message IDs only B->>A: tickets.mine A-->>B: Ticket records with presigned S3 URLsLegend: highlighted group = the serverless part; everything here is built in code.
The pattern has a name: transactional outbox plus fan-out. The database decides; the event is a fact about a decision already made. Every later step can fail and retry without undoing the booking.
Under the hood
Section titled “Under the hood”Each service on the map owns one guarantee, and the design works because no step asks a service for a guarantee it does not give.
| Boundary | What it guarantees | What it does not |
|---|---|---|
| Postgres transaction | Booking and outbox row commit together, or neither does | Delivery to anyone |
| Outbox relay → SNS | Publishes until SNS accepts; at least once | Exactly once: a crash after publish and before “sent” publishes again |
| SNS → SQS | Each subscribed queue gets a copy, retried by SNS | Order; that the consumer succeeds |
| SQS + event source mapping | A message stays until deleted; failures return after the visibility timeout; 3 receives then DLQ | No duplicates: delivery is at least once |
| Lambda handler | Reports failed message IDs so only those retry | Atomic “send and record”: we send first, record second |
| DynamoDB conditional write | One winner per key (attribute_not_exists) | Cross-service transactions |
| SES | Accepted for delivery | Delivered to the inbox |
Read down the right column. Every “not” is handled by the step after it: idempotency keys in the consumers, the DLQ for poison messages, a business key (booking.confirmed:<bookingId>) instead of a message ID. That is what “effectively once” means in our code. See the SQS, SNS and DynamoDB pages for each mechanism.
Why the API is not serverless. The API keeps a Postgres connection pool and a Redis connection, serves tRPC to a Next.js app on the same origin, and runs the outbox relay loop. Those want a long-running process. Lambda would mean a new connection per environment (needing RDS Proxy), cold starts on user requests, and an outbox loop that cannot run between invocations. The work after a booking is the opposite: bursty, short, event-driven and idle most of the day. That is where Lambda wins.
Cost shape. SNS, SQS, Lambda, DynamoDB on-demand, S3 and SES bill per request or per GB, with no idle fee. That is why they stay on when the cost switches turn ECS, RDS and the load balancer off. The exceptions are small standing charges such as the ticket-signing secret. See Cost switches.
Limits and numbers
Section titled “Limits and numbers”At the time of writing; check the AWS docs. Each value is sourced on its own deep-dive page; this table collects the ones that shape the whole design.
| Item | Value | Page |
|---|---|---|
| Lambda max run time | 15 minutes; ours: worker 10 s, tickets 30 s | Lambda |
| Lambda account concurrency | General default 1,000; ours recorded as 10 (not re-read) | Lambda |
| Our mapping caps | Worker: batch 10, max 5 in parallel. Tickets: batch 1, max 2 | queue-trigger.ts, tickets/lambda.ts |
| SQS message retention | Up to 14 days | SQS |
| Message size | SQS up to 1 MiB; SNS 256 KiB by default, configurable up to 1 MiB; EventBridge Classic PutEvents request under 1 MB | SQS, SNS, EventBridge |
| Step Functions Standard | Up to 1 year per execution | Step Functions |
| API Gateway HTTP API | 30 s integration timeout | API Gateway |
| AppSync | 30 s request; 240 KB subscription payload | AppSync |
| Scheduler | 60-second precision; 10 million schedules per Region by default | EventBridge |
| SES sandbox | 200 per 24 h, 1 per second, verified recipients only | SES |
| S3 presigned URL | Ours 120 s | S3 |
Our two mapping caps add up to 7 concurrent invocations, which fits under a recorded account limit of 10. If that limit is real today, a third Lambda consumer (reminders, live updates) must fit in the remaining 3, or the account limit must be raised first.
In MiniTickets
Section titled “In MiniTickets”Built in code: SNS topic, two SQS queues with DLQs, two event source mappings, two Lambda functions, two DynamoDB tables, one S3 bucket, SES identities. Facts on each page were checked in the infra/src/jobs and infra/src/tickets folders, apps/worker/src and apps/api/src. Live AWS state was not checked.
Designed, not built: Step Functions booking saga with payment, API Gateway check-in scanner, AppSync live seat hints, EventBridge Scheduler reminders. Each design reuses what exists: the outbox event as the trigger, a new queue per new consumer, the processed-jobs pattern for idempotency, and the booking transaction as the only judge of seats.
The code entry points:
apps/api/src/services/outbox.ts→startOutboxRelay: the one place events leave the database.infra/src/jobs/booking-events.ts→createBookingEvents: the topic and its two queue subscriptions.apps/worker/src/handlers/booking-confirmed.ts→handleBookingConfirmed: the idempotent email consumer.apps/api/src/queries/assets.ts→signAssetGet: the presigned URL boundary to S3.
Connections
Section titled “Connections”Every arrow on the built map, and exactly what allows it. The map’s permission view has the full statements.
| From → to | What allows it |
|---|---|
| API task → SNS topic | Task role sns:Publish on the booking-events topic ARN only |
| SNS → jobs queue, tickets queue | Each queue policy allows sns.amazonaws.com sqs:SendMessage with aws:SourceArn = the topic and aws:SourceAccount = our account |
| Jobs queue → worker Lambda | Event source mapping; worker role sqs:ReceiveMessage, DeleteMessage, ChangeMessageVisibility, GetQueueAttributes |
| Worker → processed-jobs table | Worker role dynamodb:GetItem, dynamodb:PutItem |
| Worker → SES | Worker role ses:SendEmail on our identities, condition ses:FromAddress |
| Tickets queue → tickets Lambda | Event source mapping; tickets role sqs:ReceiveMessage, DeleteMessage, GetQueueAttributes |
| Tickets Lambda → tickets table, S3, signing secret | Tickets role dynamodb:GetItem/PutItem/UpdateItem, s3:PutObject on tickets/*, secretsmanager:GetSecretValue on one secret |
| API task → tickets table, S3 | Task role dynamodb:Query; s3:GetObject on tickets/*, posters/*, poster-uploads/*; s3:PutObject on the poster prefixes with AES256 |
No serverless arrow uses a security group. Lambdas here run outside the VPC, and SNS, SQS, DynamoDB, S3 and SES are reached over HTTPS with IAM. That is a design choice: the Lambdas cannot reach Postgres or Redis, and they do not need to, because every message carries what its consumer needs.
When it fails
Section titled “When it fails”“The customer got no email” crosses six boundaries. Find the first one where the booking stopped.
flowchart TB start["Booked, no email"] --> outbox{"Outbox row sent?"} outbox -->|"no, pending"| snsdown["Relay cannot publish: SNS or task role"] outbox -->|"yes"| dlq{"Message in jobs DLQ?"} dlq -->|"yes"| failed["Worker failed 3 times: read its logs"] dlq -->|"no"| sent{"Worker logged SES accepted?"} sent -->|"no"| stuck["Still in queue, or skipped as duplicate"] sent -->|"yes"| after["After SES: bounce, spam, suppression"] class start,snsdown,failed,stuck,after failure class outbox,dlq,sent securityLegend: amber = questions to ask in order; red = where the booking stopped. Use the correlation ID from the booking request to search every log.
| What you see | Why | Fix |
|---|---|---|
| Outbox rows stay pending | Relay cannot publish: SNS unreachable, topic ARN missing, or task role lost sns:Publish | Fix and wait: the relay retries; rows are not lost |
| Messages pile up in the queue, Lambda not invoked | Mapping disabled, or Lambda throttled at the account concurrency limit | Check the mapping state and Throttles; raise the limit or lower other consumers |
| DLQ grows | Worker throws on every try: SES rejection, bad message, missing permission | Read the worker’s log line for the message; fix; then redrive |
| Two emails for one booking | At-least-once delivery hit the send-before-record gap | Expected rarely; see the duplicate emails case |
| Email accepted, never arrived | Bounce, spam folder or suppression after SES | Add SES event publishing; see SES |
At 10x / 100x
Section titled “At 10x / 100x”At 10x, the serverless half absorbs a sale-day burst by queueing. Bookings commit at Postgres speed; emails and tickets follow at the speed the mapping caps allow. That delay is a choice: the caps protect SES’s sending rate and the account’s Lambda concurrency. Watch queue age, not just queue depth.
At 100x, the limits that matter are account-level and outside our code: Lambda account concurrency, SES sending rate and reputation, DynamoDB hot keys on the event-date index, and SNS and SQS request costs. Raise quotas before the sale, not during it. Add consumers as new subscriptions with their own queues and DLQs, never by adding work to an existing consumer.
What would change the architecture? If many teams needed booking events, the outbox would publish to an EventBridge bus instead of SNS, for per-consumer rules, filtering and replay. If booking added payment, the flow would become a Step Functions saga. If the API needed to scale to zero, a Lambda API would need RDS Proxy and a separate relay. None of these is needed today.
Common interview traps
Section titled “Common interview traps”- “Serverless means no servers to think about.” You still own concurrency limits, timeouts, retries, quotas and idempotency. The servers are hidden; the distributed system is not.
- “SQS and SNS do the same thing.” SQS holds work for one consumer group until it is deleted. SNS pushes a copy to every subscriber and keeps nothing.
- “Use Step Functions for everything async.” It tracks a multi-step process with state. A single “send an email” job is a queue and a function.
- “Lambda is always cheaper than containers.” For steady, high traffic with long connections, a container is often cheaper and simpler. Lambda wins on bursty, idle-most-of-the-time work.
- “At-least-once is a bug to fix.” It is the contract. Design consumers to be idempotent.
Design question
Section titled “Design question”Which service for which job? This is the table to carry into an interview.
| Job | Pick | Why | MiniTickets |
|---|---|---|---|
| Buffer work for one consumer, retry, DLQ | SQS | Holds messages until processed; visibility timeout and redrive | Built: jobs, tickets |
| Copy one event to several consumers | SNS | Push fan-out, no storage, filter policies | Built: booking-events |
| Route many event types to many consumers by content; replay | EventBridge bus | Rules, patterns, archive and replay, AWS/SaaS sources | Not needed yet |
| Run something at a time | EventBridge Scheduler | One-time, cron, rate, time zones | Designed: reminders |
| Multi-step process with waits and compensation | Step Functions | State, retries per step, waits up to a year | Designed: booking saga |
| Short event-driven code | Lambda | Scales per event, no idle cost, 15-minute cap | Built: worker, tickets |
| Long-running app, connection pools | ECS / Fargate | Process stays up; no cold starts per request | Built: API, web |
| Key-value lookups, conditional writes | DynamoDB | Single-digit-millisecond keys, conditions | Built: two tables |
| Files and images | S3 | Objects, presigned URLs | Built: ticket assets |
| Transactional email | SES | Verified identities, DKIM | Built, sandbox |
SQS vs SNS vs EventBridge vs Step Functions.
| SQS | SNS | EventBridge | Step Functions | |
|---|---|---|---|---|
| Model | Queue, consumers pull | Topic, pushes to subscribers | Bus, rules route by content | State machine runs steps |
| Keeps messages | Up to 14 days | No (FIFO topics: optional archive) | Archive (Classic) or retention (newer bus) | Execution history |
| Consumers per message | One group | Many | Many, by rule | One workflow |
| Ordering | FIFO queues | FIFO topics | Newer bus: per event group | Steps in defined order |
| Use it when | Work must not be lost | Plain fan-out | Many sources, filtering, replay | You need to know where a process is |
API Gateway vs AppSync vs ALB.
| ALB | API Gateway | AppSync | |
|---|---|---|---|
| Contract | Any HTTP to targets (ECS, EC2, IP, Lambda) | REST, HTTP or WebSocket routes | GraphQL schema, subscriptions |
| Auth built in | Optional OIDC/Cognito at the listener | IAM, JWT, Lambda authorizers, API keys | API key, IAM, Cognito, OIDC, Lambda |
| Extras | Path rules, health checks, target groups | Throttling, usage plans, REST caching | Resolvers, filtered subscriptions, caching |
| Ours | Built: routes /trpc/* to the API | Designed: check-in scanner | Designed: live seat hints |
Lambda vs ECS. Choose Lambda for short, bursty, event-driven work with nothing to keep in memory between events. Choose ECS for long-lived processes, steady load, connection pools, background loops and full control of the runtime. MiniTickets uses both: ECS for the request path, Lambda for the event path.
flowchart TB job["New async job"] --> q1{"Several consumers?"} q1 -->|"no"| sqs["SQS + Lambda"] q1 -->|"yes"| q2{"Content routing or replay?"} q2 -->|"no"| sns["SNS to one SQS per consumer"] q2 -->|"yes"| eb["EventBridge bus"] sqs --> q3{"Multi-step with compensation?"} q3 -->|"yes"| sfn["Step Functions"] class job app class q1,q2,q3 security class sqs,sns aws class eb,sfn serviceLegend: amber = the question; AWS = what we built; highlighted = what we would add when the answer changes.
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Why SNS to SQS rather than SNS straight to Lambda?
Why an outbox instead of publishing in the request?
When would you move to EventBridge?
What is the first thing to break on sale day?
Could the whole API run on Lambda?
Remember it
Section titled “Remember it”- Postgres decides; events report decisions already made (transactional outbox).
- SNS copies, SQS holds, Lambda processes, DLQ catches poison.
- Every consumer is idempotent by a business key, because delivery is at least once.
- Pick services by job: queue, fan-out, routing, workflow, front door, compute.
- ECS for the long-running request path, Lambda for the bursty event path.
Advanced
Section titled “Advanced”Advanced: Read-only checks across the serverless half
One pass over the built serverless pieces, without touching data:
aws sns list-subscriptions-by-topic --topic-arn "$TOPIC_ARN" --region us-east-1aws sqs get-queue-attributes --queue-url "$JOBS_QUEUE_URL" --attribute-names ApproximateNumberOfMessages ApproximateAgeOfOldestMessage RedrivePolicy --region us-east-1aws lambda list-event-source-mappings --function-name minitickets-dev-worker --region us-east-1aws lambda get-account-settings --region us-east-1aws dynamodb describe-table --table-name minitickets-dev-processed-jobs --region us-east-1aws sesv2 get-account --region us-east-1These only read. get-account-settings shows the account’s real concurrency limit; compare it with the 7 our mappings can use.
Where the designs plug in. Every designed service enters the same way: a new SNS subscription to a new SQS queue with its own DLQ, then a new Lambda with its own role. The exceptions are API Gateway (a new front door for scanners) and Step Functions (it would replace part of the booking request itself). That is a useful interview line: “new consumers never change old ones”.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- Why is a booking already safe before any serverless service runs?
- What does each queue give its consumer that a direct SNS-to-Lambda subscription would not?
- Which service would you pick to run a job at 19:30 tomorrow, and which for a payment saga?
- Why does the API run on ECS while the workers run on Lambda?
- What account-level number could stop a third Lambda consumer from running at all?
Redraw it from memory: the API, SNS, two SQS queues, two Lambdas, DynamoDB, S3 and SES, then add the four designed services with dashed lines
Try this: open the map's booking flow, stop at the SNS step, and name the guarantee each later boundary does and does not give
Read this book online: https://book.theversehub.in/