Filter concepts by levelShowing all levels.

AWS · Section 55

Cloud Architecture Patterns

Level
advanced
Read
55 min
Concepts
5

Five shapes cover most of what gets built on AWS, and each one is a different answer to one question: is someone waiting for this? The three-tier web application answers yes with long-running compute — Route 53 and CloudFront at the edge, an ALB in public subnets, containers or instances in private subnets, and RDS, ElastiCache and S3 behind them, with traffic moving strictly downward and security groups referencing each other rather than CIDR ranges. The serverless API answers yes with per-request compute: API Gateway in front of Lambda in front of DynamoDB and S3, with a hard 15-minute ceiling, a 6 MB payload limit, and a 10,000-requests-per-second front door sitting in front of a 1,000-execution default concurrency. The other three answer no. The event-driven platform publishes past-tense facts to EventBridge or SNS and gives every consumer its own SQS queue and dead-letter queue, so a new consumer redeploys nothing upstream and the price is eventual consistency. The data ingestion pipeline lands raw data immutably before transforming it, so a parsing bug found three weeks later costs a backfill instead of the data. The file-processing pipeline hands the browser a presigned URL so bytes never touch your compute, then turns the S3 object event into queued work. Everything after the front door in all three is at-least-once, which is why a conditional claim and a deterministic output key show up in every one of them.

What is true here

  1. Every pattern is a composition of services taught earlier — what differs is which trade-off it makes.
  2. Split the design on "is anyone waiting?" before choosing any service.
  3. Fan-out needs one queue per consumer, or the consumers stay coupled.
  4. Raw data is the only copy you cannot rebuild, so nothing transforms on the way in.
  5. At-least-once delivery is the default everywhere; idempotent handlers are not optional.

What you will be able to do

  • Draw a three-tier application and justify every subnet, route and security group in it
  • Decide what belongs on the synchronous path of an API and what has to be deferred
  • Design a fan-out where adding a consumer requires no upstream change
  • Lay out a data pipeline that can be reprocessed after a transform bug
  • Build an upload flow where the file never passes through your application tier
Five shapes, and the question each one answers
steady traffic,long connectionsspiky traffic,short workseveralconsumersone artifactper jobanalytics isthe consumer

Someone is waiting

a request needs an answer now

Three-tier web application

long-running compute behind a load balancer

Serverless API

per-request compute behind API Gateway

Nobody is waiting

work that must not block a caller

Event-driven platform

many consumers of one fact

File processing

one object in, one object out

Data ingestion

records in, queryable tables out

  • Someone is waiting — a request needs an answer now
    • leads to Three-tier web application (steady traffic, long connections)
    • leads to Serverless API (spiky traffic, short work)
  • Three-tier web application — long-running compute behind a load balancer
  • Serverless API — per-request compute behind API Gateway
  • Nobody is waiting — work that must not block a caller
    • leads to Event-driven platform (several consumers)
    • leads to File processing (one artifact per job)
    • leads to Data ingestion (analytics is the consumer)
  • Event-driven platform — many consumers of one fact
  • File processing — one object in, one object out
  • Data ingestion — records in, queryable tables out

Cloud Architecture Patterns

The five recurring shapes — three-tier, serverless API, event-driven platform, data ingestion, and file processing — each drawn end to end, with the limits and failure modes that shape them.

Three-Tier Web Application

coreadvanced

The three-tier pattern splits a web application into a presentation tier a browser talks to, an application tier that runs your code, and a data tier that stores state. On AWS that is Route 53 and CloudFront at the edge, an Application Load Balancer in public subnets, containers or instances in private subnets, and RDS plus ElastiCache plus S3 behind them. Every tier sits in at least two Availability Zones.

Think of it as

Read the diagram as a one-way street. Each tier only accepts traffic from the tier above it, and only the top tier is reachable from the internet. That single rule decides every subnet, route table and security group in the design — a tier that can be reached from two directions is a tier you cannot reason about.

What we're doing: Follow one page request through every hop, and name what each hop is for.

request-path.txttext
1. Browser asks for app.example.com
   Route 53 alias record answers with the CloudFront distribution.

2. CloudFront edge location receives the request
   /static/* is a cache hit, served from the edge, origin never touched.
   /api/*    is not cacheable, forwarded to the ALB origin over HTTPS.

3. ALB receives the forwarded request
   Listener rule on host + path picks the target group.
   Health check has already removed any unhealthy task.

4. An ECS task in a private subnet handles it
   Reads session from ElastiCache. Cache miss on the product
   record, so it queries RDS and writes the result back to the cache.

5. RDS writer answers the query
   The task never knows which physical instance it reached — the
   Multi-AZ endpoint is a DNS name that moves on failover.

6. Response travels back up the same chain
   ALB → CloudFront → browser. Nothing new is opened downward.
1
One domain covers both static and dynamic traffic, because CloudFront routes by path to two different origins.
7
The ALB is the only component in a public subnet, and the only thing the edge can reach.
13
Cache-aside: the application, not the cache, decides what to store and when. See the ElastiCache section.
18
The application connects to an endpoint name, never an IP — this is what makes failover survivable without a deploy.

Why this works: The value of the pattern is not the box diagram, it is that every hop has one job and one caller. When a request fails you can name which hop it died at, and when you scale you can scale exactly one tier. A design where the browser talks to two of these tiers directly loses both properties at once.

Putting the database in a public subnet "so it can be reached from a laptop"

Wrong

text
# db subnet route table
0.0.0.0/0 → igw-0a1b2c3d      # now publicly routable
# sg-rds inbound 5432 from 0.0.0.0/0

Better

text
# db subnet route table: local route only, no default route
# sg-rds inbound 5432 from sg-app
# Human access goes through Session Manager port forwarding
# or a bastion in a public subnet — not through the database.

What you see: The database is reachable from anywhere on the internet, and the first sign of it is credential-stuffing traffic in the engine logs.

Why: A subnet is public when its route table has a path to an internet gateway — that route, not the resource, is the decision. Once the data tier has one, every other control is the only thing left standing between the internet and your data.

Every tier, every boundary, one direction of traffic

Traffic only ever moves downward. Each tier spans two Availability Zones, and only the public subnets have a route to the internet gateway.

  • A five-band diagram, read top to bottom, with an arrow between each band.
  • Band 1, Client: users on the internet.
  • Band 2, Edge (global): Route 53 resolves the domain, CloudFront and AWS WAF terminate TLS and filter requests, and S3 holds static assets served through CloudFront.
  • Band 3, Public subnets across two Availability Zones: the Application Load Balancer, and a NAT gateway for outbound-only traffic from private subnets.
  • Band 4, Private application subnets across two Availability Zones: ECS tasks or EC2 instances in an Auto Scaling group, one set per zone.
  • Band 5, Private data subnets across two Availability Zones: RDS with a Multi-AZ standby, and ElastiCache.
  • Footnote: only band 3 has a route to an internet gateway, and each tier accepts traffic only from the tier directly above it.

Each tier: what it owns, where it lives, what reaches it

Each tier: what it owns, where it lives, what reaches it
TierAWS servicesSubnetInbound from
EdgeRoute 53, CloudFront, AWS WAF, ACMNone — globalThe internet
PresentationApplication Load BalancerPublic, one per AZCloudFront (and only CloudFront, if locked down)
ApplicationECS/Fargate tasks or EC2 in an Auto Scaling groupPrivate, one per AZThe ALB security group
DataRDS or Aurora, Multi-AZPrivate data subnet, no default routeThe application security group
CacheElastiCachePrivate data subnetThe application security group
ObjectsS3None — regional, reached via a gateway endpointThe application role, and CloudFront via OAC

Together

text
# The chain as security groups — each rule names the group above it
sg-alb    inbound 443 from 0.0.0.0/0        (or from the CloudFront prefix list)
sg-app    inbound 8000 from sg-alb
sg-rds    inbound 5432 from sg-app
sg-cache  inbound 6379 from sg-app
# No rule anywhere names a CIDR inside the VPC. Add an AZ, add a task,
# replace an instance — none of these rules change.

Remember: Route 53 and CloudFront at the edge, ALB in public subnets, application in private subnets, data in private subnets with no default route — traffic only ever moves downward, security groups reference the group above rather than a CIDR, and the application tier holds no state.

See also: public to private design · multi az subnet architecture · serverless api · mapping a backend to aws

Serverless API

coreadvanced

A serverless API replaces the load balancer with API Gateway and the always-on application tier with Lambda functions. Route 53 points a custom domain at API Gateway, each route invokes a function, and the function reads and writes DynamoDB or S3. Anything slow is not done in the request — it is published to EventBridge or SQS and handled by a second function.

Think of it as

Draw a vertical line through the diagram: everything to the left of it must finish inside the client's patience, and everything to the right of it must not. The synchronous path is short by design; the queue is where all the work that would blow the timeout goes.

What we're doing: Design "submit an order" so the caller waits for a decision, not for the work.

submit-order.txttext
SYNCHRONOUS — must finish in a few hundred milliseconds
  POST /orders  →  API Gateway (JWT authorizer, throttle)
                →  Lambda create_order
                →  DynamoDB PutItem with
                     ConditionExpression: attribute_not_exists(order_id)

  Returns 201 with the order id. That is the whole contract.
  A retry of the same request hits the condition and is rejected,
  so a client that retries on a timeout cannot create two orders.

ASYNCHRONOUS — the caller has already gone
  create_order also puts an OrderPlaced event on EventBridge.

  EventBridge rule  →  SQS queue  →  Lambda charge_payment
  EventBridge rule  →  SQS queue  →  Lambda send_confirmation
  EventBridge rule  →  SQS queue  →  Lambda update_inventory

  Each worker is idempotent, each has its own dead-letter queue,
  and each can fail and be redriven without touching the others.
1
Deciding what belongs on this side is the entire design decision — everything else follows from it.
6
The condition expression is what makes the endpoint safe to retry; without it a network timeout becomes a duplicate order.
12
One event, three independent consumers. Adding a fourth does not touch create_order.
17
Separate queues mean a failing payment processor does not stall confirmation emails.

Why this works: Serverless pushes you toward this split whether you plan for it or not, because the timeout is a hard ceiling rather than a slow degradation. Deciding the split deliberately gives you a fast, retry-safe API and a set of workers that can each fail on their own; deciding it accidentally gives you one function doing everything, timing out at 15 minutes with no record of how far it got.

Doing the slow work inside the request handler

Wrong

text
# create_order: write the order, charge the card, call the shipping
# API, render a PDF, send the email — then return 201.

Better

text
# create_order: write the order, publish OrderPlaced, return 201.
# Four workers consume the event, each retried and DLQ'd separately.

What you see: p99 latency tracks whichever downstream service is slowest today, and a payment-provider outage returns 5xx on order creation even though the order was already saved.

Why: Every synchronous dependency is added to your own availability and latency. A handler that calls four services is only up when all four are up, and the caller pays for the slowest. The queue converts those dependencies from availability requirements into retryable work.

The synchronous path, and everything deliberately pushed off it

Left of the dashed line is the request the caller waits on. Right of it is work that must not be allowed to affect the caller's latency or the API's timeout.

  • A diagram split by a vertical dashed line into a synchronous path on the left and asynchronous work on the right.
  • Synchronous path, left to right: a client calls Route 53, which resolves a custom domain to API Gateway; API Gateway invokes a Lambda function; the function reads and writes DynamoDB and S3 and returns a response.
  • The function also publishes an event across the dashed line.
  • Asynchronous side: the event goes to EventBridge or SQS, which invokes a worker Lambda, which does the slow work and writes back to DynamoDB and S3; failed messages land in a dead-letter queue.
  • Note: the caller never waits for anything on the right-hand side.

Which limit bites first, and what it turns into

Which limit bites first, and what it turns into
LimitDefaultWhat the caller sees
API Gateway throttle10,000 requests per second429 Too Many Requests at the front door
Lambda concurrent executions1,000 per Region, adjustable429 from Lambda — the request reached the function and was refused
Lambda timeout900 seconds (15 minutes) maximumFunction killed mid-work; the invocation is retried if asynchronous
Lambda synchronous payload6 MB request and 6 MB responseRequest rejected — pass an S3 key instead of the object
Lambda asynchronous payload1 MBEvent rejected at publish time, not at handling time
Lambda memory128 MB to 10,240 MB, CPU scales with itA CPU-bound function is slow until memory is raised

Together

text
# The mismatch worth checking on day one
API Gateway default   10,000 rps
Lambda default         1,000 concurrent executions

# A burst that API Gateway happily accepts can be throttled one hop
# later. Either raise the Lambda concurrency quota to match expected
# traffic, or put a queue between the two so the burst becomes depth.

Remember: Route 53 → API Gateway → Lambda → DynamoDB/S3 for anything the caller waits on, EventBridge or SQS → worker Lambdas for everything else. Watch the 10,000 rps front door against the 1,000 default concurrency behind it, keep payloads under 6 MB by passing S3 keys, and make every asynchronous handler idempotent.

See also: serverless composition patterns · cold starts and concurrency · event driven platform · idempotency mechanisms on aws

Event-Driven Platform

coreadvanced

An event-driven platform replaces direct service-to-service calls with a bus. A producer announces that something happened; a router copies that event to every interested consumer's own queue; each consumer drains its queue at its own pace. The producer never learns who consumed the event, which is the whole point — new consumers are added without changing it.

Think of it as

The bus fans out, the queue absorbs. Those are two different jobs and both are needed: EventBridge or SNS decides who gets a copy, and each consumer's SQS queue holds that copy until the consumer is ready. Fan-out without a queue per consumer means one slow consumer becomes everyone's problem.

What we're doing: Add a fourth consumer to a live platform without touching the producer.

add-a-consumer.txttext
Today: OrderPlaced → billing, search index, notifications.
Ask: "finance wants a ledger entry for every order."

In a request/response design this is a change to the orders
service: a new outbound call, a new failure mode in the
checkout path, a new deploy, a new rollback risk.

In this design it is three additions, none of them upstream:
  1. new SQS queue  ledger-work  + its dead-letter queue
  2. new EventBridge rule, same pattern, new target
  3. new consumer that reads ledger-work

The orders service is not redeployed, not restarted, and does
not know the ledger exists. If the ledger consumer breaks on
day one, ledger-work grows and the other three keep running.

What you have taken on: the ledger is eventually consistent.
"Order placed" and "ledger entry written" are two moments, and
any report that assumes they are one moment will be wrong.
1
The starting point matters less than what changes when a requirement arrives — that is the property being bought.
8
All three changes are additive and reversible. Deleting the rule removes the consumer with no upstream coordination.
14
The honest cost, stated up front: this is what you traded the coupling for, and it is not free.

Why this works: Event-driven architecture is usually sold on decoupling, which is real but vague. The concrete test is this one: when a new consumer appears, how many existing services get redeployed? A design where the answer is zero absorbs new requirements cheaply. The bill comes due as eventual consistency and as harder debugging, both of which have to be paid deliberately.

One shared queue behind the topic, drained by every worker

Wrong

text
# SNS topic → one SQS queue → billing, indexer and notifier all
# poll it and skip the messages that are not theirs.

Better

text
# SNS topic → three subscriptions → three queues, one per consumer.
# Each consumer only ever sees messages it is meant to handle.

What you see: A message the indexer cannot parse is received, skipped and returned by every worker in turn until it exhausts its retries — and while that happens the queue depth grows for everyone.

Why: A shared queue re-couples the consumers you separated: they compete for the same messages, share one visibility timeout, share one dead-letter queue, and cannot be scaled or paused independently. The per-consumer queue is what makes the fan-out real rather than cosmetic.

One event, one copy per consumer, one queue each

The bus decides who gets a copy. The queue in front of each worker is what stops a slow or broken consumer from affecting the producer or the other consumers.

  • A fan-out diagram, left to right.
  • On the left, three producers — the orders service, the web API, and a scheduled job — all publish to a single event bus, EventBridge or SNS, in the middle.
  • The bus matches rules and delivers one copy of the event to each of three separate SQS queues.
  • Each queue feeds its own worker: billing, search indexing, and notifications. Each worker writes to its own database or external system.
  • Each queue has its own dead-letter queue beneath it.
  • Note: the producers never learn which consumers exist, and one blocked consumer only backs up its own queue.

Choosing the router, and what each one is actually for

Choosing the router, and what each one is actually for
RouterSelection modelReach for it when
EventBridgeContent-based rules matched against the whole event JSONMany event types on one bus, routing on fields, archive and replay, cross-account delivery
SNSTopic subscription, with optional message filtering on attributesOne event type, high fan-out, low latency, simplest possible wiring
SQS aloneNo routing — one producer, one consumer groupYou need a buffer between two components, not a fan-out
KinesisOrdered shards, consumers track their own positionOrder matters, or several consumers must replay the same stream from a chosen point
Step FunctionsExplicit state machine you authorThe sequence itself is the product and someone has to see where an execution is

Together

text
# The usual composition — not either/or
EventBridge rule  matches {"detail-type": ["OrderPlaced"]}
     ↳ target: SQS queue billing-work        → Lambda charge
     ↳ target: SQS queue search-index-work   → ECS indexer
     ↳ target: SQS queue notify-work         → Lambda email

# EventBridge chooses recipients; SQS gives each recipient a buffer
# and its own retry and dead-letter behaviour.

Remember: Producers publish facts to EventBridge or SNS; the router copies each event into one SQS queue per consumer; each consumer drains at its own pace with its own dead-letter queue. Delivery is at least once, so handlers are idempotent, and the price of the decoupling is eventual consistency.

See also: loosely coupled event driven design · sns sqs fanout pattern · domain events vs commands · file processing pipeline

Data Ingestion Pipeline

coreadvanced

A data ingestion pipeline moves data from wherever it is produced into a shape analysts can query. Records arrive as files in S3 or as a stream in Kinesis, land untouched in a raw zone, are cleaned and reshaped by a processing step, and are written to a curated zone that reporting and dashboards read. The raw copy is kept so the processing step can be rewritten and rerun.

Think of it as

Think of the raw zone as the tape you never erase. Everything after it is derived and can be rebuilt; the raw landing zone cannot. That is why ingestion writes raw first and transforms second, rather than transforming on the way in — a bug in the transform costs a rerun, not the data.

What we're doing: Recover from a transform bug discovered three weeks after it shipped.

reprocess.txttext
The bug: currency amounts parsed as integers, so every order
from the three markets that use decimal minor units is 100×
too large in the curated table. Shipped 21 days ago.

What is damaged: curated/orders for 21 days, and every
dashboard and extract built on top of it.

What is not damaged: raw/orders. It holds exactly the bytes
that arrived, with no parsing applied, for every one of those
days — because the ingestion step never transformed anything.

The recovery:
  1. fix the parser, deploy the transform
  2. rerun it over raw/orders/ingest_date=2026-08-14..2026-09-04
  3. the job overwrites the affected curated partitions
  4. downstream aggregates rebuild from curated

No source system is asked to resend. Nothing is reconstructed
by hand. The whole incident costs one backfill run.
1
A parsing bug that survives weeks is the normal case, not the exceptional one — pipelines are rarely watched closely enough to catch it on day one.
6
Everything derived is damaged together, which sounds bad and is actually the good outcome: one rerun fixes all of it.
13
Reprocessing is a range of partitions, which is only possible because raw is partitioned by arrival time.

Why this works: The raw zone earns its storage cost exactly once, on the day you need it, and then it pays for itself completely. A pipeline that transforms on the way in has no such day: the only copy of the data is the wrong one, and recovery means asking every upstream system to resend — which some of them cannot do.

Querying the raw zone directly because "the data is already there"

Wrong

text
-- Dashboard query, straight at the landing prefix
SELECT * FROM raw_orders_json WHERE order_date = current_date;

Better

text
-- Against the curated table: partitioned, typed, columnar
SELECT * FROM curated.orders WHERE order_date = current_date;

What you see: Queries scan gigabytes of compressed JSON to answer a question about one day, cost climbs with every new dashboard, and a change to an upstream export format breaks the dashboards directly.

Why: Raw has no schema guarantee, no partitioning that matches how anyone queries, and no protection from upstream format changes — those are exactly the things the curated zone exists to provide. Querying raw also makes the raw zone a contract, which means you can no longer change how it is laid out.

Raw, curated, consumed — and what may be rewritten
arriveland as-isreadwritequeryrerun from hereafter a fix

Sources

apps, devices, exports, third parties

S3 or Kinesis

files for batch, stream for continuous

Raw zone

immutable, never edited, never deleted early

Processing

validate, deduplicate, reshape, partition

Curated zone

partitioned Parquet, one schema, documented

Analytics

SQL queries, dashboards, models

  • Sources — apps, devices, exports, third parties
    • leads to S3 or Kinesis (arrive)
  • S3 or Kinesis — files for batch, stream for continuous
    • leads to Raw zone (land as-is)
  • Raw zone — immutable, never edited, never deleted early
    • leads to Processing (read)
  • Processing — validate, deduplicate, reshape, partition
    • leads to Curated zone (write)
    • leads to Raw zone (rerun from here after a fix)
  • Curated zone — partitioned Parquet, one schema, documented
    • leads to Analytics (query)
  • Analytics — SQL queries, dashboards, models

The zones, and the rule that holds for each

The zones, and the rule that holds for each
ZoneContentsWritten byRule
Raw / landingExactly what arrived, unparsedIngestion onlyNever edited in place; the only zone that cannot be rebuilt
Processed / stagingParsed, typed, deduplicatedThe transform jobDisposable — deleting it costs one rerun
CuratedPartitioned columnar tables with a published schemaThe transform jobSchema changes are versioned, because queries depend on it
ConsumptionAggregates, extracts, feature tablesDownstream jobsDerived from curated only, never from raw directly

Together

text
# One prefix layout that makes the zones and partitions visible
s3://acme-lake/raw/orders/ingest_date=2026-09-04/part-0001.json.gz
s3://acme-lake/curated/orders/order_date=2026-09-04/part-0001.parquet

# raw is partitioned by WHEN IT ARRIVED  — so a rerun is a prefix
# curated is partitioned by WHEN IT HAPPENED — so a query is a prefix

S3 or Kinesis for the arrival step

S3 or Kinesis for the arrival step
AspectS3 as the entry pointKinesis as the entry point
Shape of arrivalWhole files, on a schedule or when producedIndividual records, continuously
Latency to curatedMinutes to hoursSeconds to minutes
OrderingNone across filesPreserved within a shard
ReplayRead the object again — it is still thereOnly within the retention period
TriggeringS3 event notification, or a scheduled jobEvent source mapping or a stream consumer
Reach for it whenExports, uploads, nightly extracts, backfillsClickstream, telemetry, application events

Together

text
# Most real pipelines use both, for different sources
nightly ERP export        →  S3  →  transform  →  curated
clickstream from the app  →  Kinesis  →  buffer to S3  →  transform

# Note the second path still lands in S3 before transforming: the
# stream is the transport, the raw zone is still the tape.

Remember: Land raw and immutable first, transform second, publish a curated zone partitioned by the column people filter on. Files and batch go through S3, continuous records through Kinesis, and both end up in the raw zone. Write whole partitions so any run is repeatable, and keep raw so a bug costs a backfill instead of the data.

See also: real time analytics and event ingestion · common s3 workload patterns · data lifecycle tiers · file processing pipeline

File Processing Pipeline

coreadvanced

A file-processing pipeline accepts an upload, does slow work on it, and tells the user when the result is ready. The upload goes straight from the browser to S3 with a presigned URL, the object landing triggers an event, a worker reads the object and writes a processed one, and a notification closes the loop. Your API never carries the bytes.

Think of it as

The request that starts the work and the work itself are two different transactions. The API only issues permission to upload and records the intent; S3 becomes the trigger; the worker is where the time goes. Any design where the API holds the file while it is processed inherits the file size and the processing time as its own latency.

What we're doing: Handle the same S3 event arriving twice without processing the file twice.

worker.txttext
Event arrives: s3://acme-uploads/uploads/t-14/j-8821/report.csv

1. Derive the job id from the key: j-8821.
   Nothing in the message body is trusted for this — the key is
   the identity, and S3 guarantees it.

2. Claim the job with a conditional write:
     UpdateItem(pk=j-8821,
                SET status='processing',
                ConditionExpression: status = 'pending')

   Condition fails  →  another delivery already claimed it.
                       Delete the message, return. Not an error.
   Condition passes →  this delivery owns the work.

3. Process. Write processed/t-14/j-8821/report.parquet.
   Same input always produces the same output key, so even a
   worker that dies after writing but before step 4 is safe.

4. UpdateItem status='done', publish JobCompleted, delete message.
1
S3 event notifications are delivered at least once — this is documented behaviour and the handler is written for it.
5
Deriving identity from the object key rather than from a message id means a replay of an old message is also handled correctly.
11
The claim is one atomic conditional write. Two concurrent deliveries race, one wins, and the loser exits cleanly.
16
A deterministic output key turns a crash between steps 3 and 4 into a harmless overwrite rather than a duplicate.

Why this works: Everything in this pipeline is at-least-once: the event notification, the queue, the retry after a visibility timeout. Rather than trying to make delivery exact, the worker is written so a second delivery is a no-op — a conditional claim at the start and a deterministic output key at the end. Those two lines are what make the pipeline safe to operate.

Uploading through the API instead of straight to S3

Wrong

text
# POST /upload with the file in the request body
# API buffers it, then puts it to S3, then returns.

Better

text
# POST /uploads → { url, fields, job_id }   (presigned)
# Browser PUTs to that URL. The API never sees the bytes.

What you see: Large uploads time out at the load balancer or API Gateway, and memory on the application tier tracks how many people are uploading rather than how many requests are in flight.

Why: Routing bytes through your compute makes file size a property of your API: payload limits, request timeouts and memory all become constraints you did not choose. The presigned URL moves the transfer to a service built for it, and leaves the API doing what it is good at — authorizing and recording.

The API hands out permission; S3 carries the bytes

Steps 1 and 2 are the only synchronous ones. Everything from step 4 onward happens after the browser has been told the upload succeeded.

  • A numbered pipeline diagram in two rows.
  • Step 1: the browser asks the API for an upload URL, and the API returns a presigned S3 URL after recording the job as pending.
  • Step 2: the browser PUTs the file directly to the S3 uploads bucket. The file never passes through the API.
  • Step 3: the object landing in S3 emits an event notification.
  • Step 4: the event goes to an SQS queue, which buffers bursts.
  • Step 5: a worker — Lambda or an ECS task — reads the object, does the slow work, and writes a processed object to a second prefix.
  • Step 6: the worker marks the job complete and publishes a notification to the user.
  • A dead-letter queue hangs off the SQS queue for objects the worker repeatedly fails on.

Each hop: what it is for, and what it must survive

Each hop: what it is for, and what it must survive
HopPurposeFailure it has to handle
API issues a presigned URLGrant one specific PUT without giving out credentialsThe user never uploads — the job row stays pending and is expired by a sweeper
Browser PUTs to S3Move bytes without touching your computeNetwork failure mid-upload — the object simply does not appear
S3 event notificationTurn "an object exists" into a messageDelivered more than once for one object
SQS queueAbsorb a burst and decouple worker scaling from upload rateA backlog, which is visible and drains, rather than throttling
WorkerDo the slow work and write the processed objectBeing killed mid-work; the message reappears after the visibility timeout
Dead-letter queueQuarantine an object the worker cannot processA poison file blocking every later upload
NotificationClose the loop with the userBeing sent twice — so it is keyed on the job, not on the message

Together

text
# The key is what makes the whole chain idempotent
key = f"uploads/{tenant_id}/{job_id}/{original_filename}"

# job_id is generated by the API and returned with the presigned URL.
# The worker derives its output key and its "already done?" check from
# the same job_id, so a redelivered event finds the work already done.

Remember: The API issues a presigned URL and records the job; the browser uploads straight to S3; the object event goes through SQS to a worker; the worker writes a processed object and notifies. Every hop is at-least-once, so claim the job with a conditional write and write a deterministic output key.

See also: common s3 workload patterns · sqs dlq redrive and idempotent consumers · event driven platform · idempotency mechanisms on aws

Advertisement