Skip to content

Serverless: the big picture

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

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?

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 designed

Legend: 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 →

Follow one booking through every built serverless piece.

  1. 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.
  2. 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.
  3. SNS copies the message to both subscribed queues, minitickets-dev-jobs and the tickets queue, with raw delivery. Each queue now holds its own copy and its own retry state.
  4. 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.
  5. 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.
  6. 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 URLs

Legend: 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.

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.

BoundaryWhat it guaranteesWhat it does not
Postgres transactionBooking and outbox row commit together, or neither doesDelivery to anyone
Outbox relay → SNSPublishes until SNS accepts; at least onceExactly once: a crash after publish and before “sent” publishes again
SNS → SQSEach subscribed queue gets a copy, retried by SNSOrder; that the consumer succeeds
SQS + event source mappingA message stays until deleted; failures return after the visibility timeout; 3 receives then DLQNo duplicates: delivery is at least once
Lambda handlerReports failed message IDs so only those retryAtomic “send and record”: we send first, record second
DynamoDB conditional writeOne winner per key (attribute_not_exists)Cross-service transactions
SESAccepted for deliveryDelivered 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.

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.

ItemValuePage
Lambda max run time15 minutes; ours: worker 10 s, tickets 30 sLambda
Lambda account concurrencyGeneral default 1,000; ours recorded as 10 (not re-read)Lambda
Our mapping capsWorker: batch 10, max 5 in parallel. Tickets: batch 1, max 2queue-trigger.ts, tickets/lambda.ts
SQS message retentionUp to 14 daysSQS
Message sizeSQS up to 1 MiB; SNS 256 KiB by default, configurable up to 1 MiB; EventBridge Classic PutEvents request under 1 MBSQS, SNS, EventBridge
Step Functions StandardUp to 1 year per executionStep Functions
API Gateway HTTP API30 s integration timeoutAPI Gateway
AppSync30 s request; 240 KB subscription payloadAppSync
Scheduler60-second precision; 10 million schedules per Region by defaultEventBridge
SES sandbox200 per 24 h, 1 per second, verified recipients onlySES
S3 presigned URLOurs 120 sS3

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.

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:

Every arrow on the built map, and exactly what allows it. The map’s permission view has the full statements.

From → toWhat allows it
API task → SNS topicTask role sns:Publish on the booking-events topic ARN only
SNS → jobs queue, tickets queueEach queue policy allows sns.amazonaws.com sqs:SendMessage with aws:SourceArn = the topic and aws:SourceAccount = our account
Jobs queue → worker LambdaEvent source mapping; worker role sqs:ReceiveMessage, DeleteMessage, ChangeMessageVisibility, GetQueueAttributes
Worker → processed-jobs tableWorker role dynamodb:GetItem, dynamodb:PutItem
Worker → SESWorker role ses:SendEmail on our identities, condition ses:FromAddress
Tickets queue → tickets LambdaEvent source mapping; tickets role sqs:ReceiveMessage, DeleteMessage, GetQueueAttributes
Tickets Lambda → tickets table, S3, signing secretTickets role dynamodb:GetItem/PutItem/UpdateItem, s3:PutObject on tickets/*, secretsmanager:GetSecretValue on one secret
API task → tickets table, S3Task 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.

“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 security

Legend: 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 seeWhyFix
Outbox rows stay pendingRelay cannot publish: SNS unreachable, topic ARN missing, or task role lost sns:PublishFix and wait: the relay retries; rows are not lost
Messages pile up in the queue, Lambda not invokedMapping disabled, or Lambda throttled at the account concurrency limitCheck the mapping state and Throttles; raise the limit or lower other consumers
DLQ growsWorker throws on every try: SES rejection, bad message, missing permissionRead the worker’s log line for the message; fix; then redrive
Two emails for one bookingAt-least-once delivery hit the send-before-record gapExpected rarely; see the duplicate emails case
Email accepted, never arrivedBounce, spam folder or suppression after SESAdd SES event publishing; see SES

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.

  • “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.

Which service for which job? This is the table to carry into an interview.

JobPickWhyMiniTickets
Buffer work for one consumer, retry, DLQSQSHolds messages until processed; visibility timeout and redriveBuilt: jobs, tickets
Copy one event to several consumersSNSPush fan-out, no storage, filter policiesBuilt: booking-events
Route many event types to many consumers by content; replayEventBridge busRules, patterns, archive and replay, AWS/SaaS sourcesNot needed yet
Run something at a timeEventBridge SchedulerOne-time, cron, rate, time zonesDesigned: reminders
Multi-step process with waits and compensationStep FunctionsState, retries per step, waits up to a yearDesigned: booking saga
Short event-driven codeLambdaScales per event, no idle cost, 15-minute capBuilt: worker, tickets
Long-running app, connection poolsECS / FargateProcess stays up; no cold starts per requestBuilt: API, web
Key-value lookups, conditional writesDynamoDBSingle-digit-millisecond keys, conditionsBuilt: two tables
Files and imagesS3Objects, presigned URLsBuilt: ticket assets
Transactional emailSESVerified identities, DKIMBuilt, sandbox

SQS vs SNS vs EventBridge vs Step Functions.

SQSSNSEventBridgeStep Functions
ModelQueue, consumers pullTopic, pushes to subscribersBus, rules route by contentState machine runs steps
Keeps messagesUp to 14 daysNo (FIFO topics: optional archive)Archive (Classic) or retention (newer bus)Execution history
Consumers per messageOne groupManyMany, by ruleOne workflow
OrderingFIFO queuesFIFO topicsNewer bus: per event groupSteps in defined order
Use it whenWork must not be lostPlain fan-outMany sources, filtering, replayYou need to know where a process is

API Gateway vs AppSync vs ALB.

ALBAPI GatewayAppSync
ContractAny HTTP to targets (ECS, EC2, IP, Lambda)REST, HTTP or WebSocket routesGraphQL schema, subscriptions
Auth built inOptional OIDC/Cognito at the listenerIAM, JWT, Lambda authorizers, API keysAPI key, IAM, Cognito, OIDC, Lambda
ExtrasPath rules, health checks, target groupsThrottling, usage plans, REST cachingResolvers, filtered subscriptions, caching
OursBuilt: routes /trpc/* to the APIDesigned: check-in scannerDesigned: 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 service

Legend: amber = the question; AWS = what we built; highlighted = what we would add when the answer changes.

Why SNS to SQS rather than SNS straight to Lambda?
Each consumer gets a queue with its own retries, batch failure reporting, DLQ and backlog. A slow ticket path never delays emails.
Why an outbox instead of publishing in the request?
Publishing inside the request can succeed while the transaction rolls back, or fail after it commits. The outbox commits the event with the booking and publishes after.
When would you move to EventBridge?
When several teams or many event types need their own filtered consumers, AWS or SaaS events join in, or you need archive and replay.
What is the first thing to break on sale day?
Likely an account-level limit, such as Lambda concurrency or SES sending rate, not our code. The queues absorb the burst; the caps decide how fast it drains.
Could the whole API run on Lambda?
Yes, with RDS Proxy for connections, the outbox relay moved to a scheduled or stream-driven function, and cold starts accepted on user requests. It trades steady container cost for those changes.
  • 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: Read-only checks across the serverless half

One pass over the built serverless pieces, without touching data:

Terminal window
aws sns list-subscriptions-by-topic --topic-arn "$TOPIC_ARN" --region us-east-1
aws sqs get-queue-attributes --queue-url "$JOBS_QUEUE_URL" --attribute-names ApproximateNumberOfMessages ApproximateAgeOfOldestMessage RedrivePolicy --region us-east-1
aws lambda list-event-source-mappings --function-name minitickets-dev-worker --region us-east-1
aws lambda get-account-settings --region us-east-1
aws dynamodb describe-table --table-name minitickets-dev-processed-jobs --region us-east-1
aws sesv2 get-account --region us-east-1

These 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”.

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

  1. Why is a booking already safe before any serverless service runs?
  2. What does each queue give its consumer that a direct SNS-to-Lambda subscription would not?
  3. Which service would you pick to run a job at 19:30 tomorrow, and which for a payment saga?
  4. Why does the API run on ECS while the workers run on Lambda?
  5. 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/