Everyone talks about writing Lambda functions. Almost nobody talks about how they get invoked, and yet the invocation model decides who owns your errors, whether retries happen and how many, what your scaling curve looks like, and which class of incident will page you. The handler code is the same in all three cases. Everything around it changes.
This article is the field guide I wish I’d had: the three invocation models with their real trade-offs, the three ways to put a Lambda behind HTTPS, and the decision framework that falls out of it all.
The three models at a glance
Every Lambda invocation in existence is one of three kinds, determined not by your code but by who calls the Invoke API and how:
- Synchronous (
RequestResponse) — the caller invokes and holds the connection open until your function returns. API Gateway, ALB, and Function URLs all work this way, as doesaws lambda invokefrom the CLI by default. - Asynchronous (
Event) — the caller hands the event to Lambda, gets a202 Acceptedimmediately, and leaves. Lambda queues the event internally and invokes your function when it can. S3 notifications, SNS, and EventBridge use this path. - Poll-based (event source mapping) — nobody pushes anything. A Lambda-managed poller reads from your SQS queue, Kinesis shard, or DynamoDB stream, assembles batches, and invokes your function synchronously on your behalf. You configure the mapping; Lambda runs the loop.
The reason this taxonomy matters more than any individual service integration: retry behavior and error ownership are properties of the invocation model, not of the function. Learn the three models and every trigger you’ll ever wire up becomes a special case of one of them.
Synchronous: the caller waits, and errors are yours
Use synchronous invocation when a human or an upstream service is actively waiting on the answer. The canonical example: a presigned S3 URL endpoint. The frontend calls your API, the function signs a URL, the response comes back in double-digit milliseconds, the browser starts uploading. Nothing about that flow tolerates “we’ll get to it eventually.”
The defining property, and the one that surprises people coming from the async side: there are no automatic retries. If the function throws, times out, or gets throttled, the error goes straight back to the caller, and the caller decides what happens next. That’s not a limitation, it’s the correct contract — Lambda has no idea whether your operation is safe to repeat, and with a live caller on the line, blind retries would just burn the timeout budget. But it means every synchronous integration needs an answer to “who retries, and how?” Usually that’s the client (with backoff and jitter), sometimes it’s a retry policy in the caller service, and occasionally the honest answer is “nobody, the user sees an error and presses the button again” — which is fine, if pressing the button again is safe. (That “if” is a whole topic; I wrote about making retries harmless with idempotency keys separately, and it applies to every model in this article.)
Two operational notes that bite in production. First, latency here is user-facing, so cold starts move from trivia to SLO material: provisioned concurrency, smaller deployment packages, and keeping the function warm-path lean all matter in a way they simply don’t for a background consumer. Second, throttling returns a 429 to the caller — a burst beyond your concurrency limit doesn’t queue, it rejects, so synchronous endpoints need either headroom or a caller that treats 429 as retryable.
The three front doors: exposing a Lambda over HTTPS
Synchronous invocation raises the practical question: how do HTTPS requests reach the function at all? AWS gives you three front doors, and choosing between them is a real architecture decision with a real cost model attached:
Function URLs are the minimal answer: one HTTPS endpoint per function, free, enabled with a checkbox or one line of CDK. Auth is either AWS_IAM (caller signs with SigV4) or NONE (public). And that’s essentially the whole feature list, which is precisely the point. There’s no throttling beyond your account’s concurrency, no usage plans, no request validation, no API keys. That makes Function URLs the right choice for webhooks (GitHub, Stripe — verify the signature in the handler), internal tooling behind IAM, and single-purpose endpoints where API Gateway would be ceremony. It makes them the wrong choice for anything public that strangers can hammer: with no throttle layer in front, a traffic spike goes straight into your concurrency and your bill.
API Gateway is the full toolbox, and the default for a real public API. Authorizers (Cognito, Lambda, IAM), per-client rate limiting and usage plans, request validation before your function even runs, custom domains, caching, canary releases, and one API fronting many functions with per-route integration. The trade is cost and a little latency: you pay per request (HTTP APIs are the cheaper, leaner flavor; REST APIs cost more and carry the legacy feature set, so pick HTTP API unless you specifically need something REST-only like usage plans or request/response transformation), and the extra hop adds single-digit milliseconds. For a product API, that’s the correct price for not reimplementing auth and throttling in every handler.
ALB is the door people forget, and it shines in exactly one scenario: Lambda living next to container or EC2 services. An Application Load Balancer routes by path or host, and a target group can be a Lambda function just as easily as a fleet of containers. So /app/* hits your ECS service, /reports/* hits a Lambda, one load balancer, one domain, one security group story. That makes ALB the natural tool for gradual migrations — carve one route out of the monolith at a time — and for teams that already pay for an ALB anyway, since its pricing is hourly plus LCUs rather than per-request, which at sustained high volume can undercut API Gateway. Mind the constraints: responses cap at 1MB (API Gateway allows 10), and there’s no built-in auth beyond OIDC at the listener.
The decision compresses well: webhook or internal tool → Function URL. Public product API → API Gateway. Lambda beside existing load-balanced services, or migrating out of them → ALB. All three are synchronous; everything from the previous section about error ownership, cold starts, and 429s applies at whichever door you pick.
Asynchronous: fire, forget, and the queue you don’t see
Use asynchronous invocation when nobody is waiting. A file lands in S3 and an email should go out; the upload must not wait for the email. An order is placed and six downstream systems care; the checkout response must not wait for any of them. The caller hands Lambda the event, receives 202 Accepted in milliseconds, and is gone.
What happens next is the part worth understanding, because it’s invisible until it isn’t:
Lambda places the event on an internal queue you don’t manage and mostly can’t see. From there it invokes your function, and on failure retries twice — roughly one minute after the first failure, roughly two minutes after the second, so three executions total for one event on a bad day. If the queue is backed up or your function is throttled, events can sit in that internal queue for hours (up to six by default, tunable down with MaximumEventAgeInSeconds). That last fact produces a classic incident shape: a burst of S3 uploads during a deploy, throttling, and then emails firing three hours later for uploads everyone had forgotten, ordered however the queue felt like draining. Async means eventually, and “eventually” has a long tail.
Two configuration decisions matter here, and both default to the wrong value for anything important:
Failure routing. After the final failed attempt, the event is discarded unless you’ve configured somewhere for it to go. The modern answer is destinations: on-failure routes the full event plus error context (error message, stack trace, request ID) to SQS, SNS, EventBridge, or another Lambda; the older DLQ setting captures only the event payload. Destinations also offer on-success, which is an underrated way to chain steps without writing orchestration code. Either way, an async Lambda without failure routing is a function that silently eats events, and you will not find out from CloudWatch — you’ll find out from a customer.
Retry count. MaximumRetryAttempts accepts 0–2. Two is right for transient failures, but if your function’s failure mode is deterministic (bad input will fail identically three times), retries just triple the noise, and 0 retries + a failure destination is the cleaner design.
And the property that makes idempotency non-negotiable on this path: delivery is at-least-once. Between Lambda’s own retries and rare internal duplicate deliveries, your function will occasionally run twice for one logical event. The async model is precisely where the claim-first idempotency pattern earns its keep — the event carries an ID, the handler claims it before acting, and the duplicate execution becomes a no-op instead of a second email.
Poll-based: Lambda works for you
The third model inverts the relationship: instead of anything pushing events at your function, Lambda polls the source on your behalf. You create an event source mapping — a managed poller — pointed at an SQS queue, a Kinesis or DynamoDB stream, or a self-managed Kafka topic. The poller reads, assembles batches, and invokes your function synchronously with each batch. Your handler receives event["Records"] and never knows a poller exists.
Use this model when you need buffering between producers and consumers. The canonical scenario: 10,000 messages spike into a queue in one minute. Nothing melts. The queue absorbs the burst, the poller feeds your function batches of up to 10 (SQS default, configurable to 10,000 with batching windows), Lambda scales consumers up gradually as the backlog grows, and your downstream database sees a controlled drip instead of a stampede. If the database still can’t keep up, you cap the pressure directly with the mapping’s maximum_concurrency — a per-queue ceiling that, unlike reserved concurrency, throttles the poller rather than causing failed invocations.
Ordering is the other axis. SQS standard gives best-effort ordering and maximal parallelism; FIFO queues give strict ordering per message group at the cost of parallelism within a group; Kinesis and DynamoDB streams process strictly in order per shard, which has a sharp consequence: a failing record blocks its entire shard, retrying until it expires or you configure bisect_batch_on_function_error / maximum_retry_attempts to break the jam. Stream pollers without those settings are head-of-line blocking incidents on a timer.
For SQS, the failure story is subtler and worth its own diagram, because the default behavior surprises almost everyone:
By default, one exception fails the entire batch: all messages return to the queue, including the ones that processed successfully, and get reprocessed. If those were “send email” messages, four customers just got duplicates because a fifth message was malformed. The fix is partial batch responses: enable ReportBatchItemFailures on the mapping, and instead of throwing, the handler catches per-record failures and returns their IDs:
def handler(event, context):
failures = []
for record in event["Records"]:
try:
process(record)
except Exception:
failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": failures}
Successful messages are deleted, only the failures return, and after maxReceiveCount deliveries the redrive policy moves the poison message to the queue’s DLQ where it can’t hurt anyone. It’s a config flag and a response shape, not a rewrite — turn it on before the poison message finds you, not after. (Note the layering: with poll-based invocation, the DLQ belongs to the queue and retry counts live in the redrive policy; Lambda’s own async retry settings and destinations don’t apply here, because from Lambda’s perspective every poller invocation is synchronous. This is the single most common confusion in the whole topic.)
One more default worth overriding on day one: an SQS mapping with a handler bug can drain your queue into failures at full speed. Set the queue’s redrive policy before wiring the mapping, size maxReceiveCount generously (5+, so transient deploy blips don’t DLQ good messages), and make the visibility timeout at least 6× the function timeout, per AWS’s own guidance, so slow processing doesn’t cause premature redelivery — which is just the retry problem from the async section wearing a different hat.
The decision framework
Strip away the service names and the choice compresses to three questions:
| Synchronous | Asynchronous | Poll-based | |
|---|---|---|---|
| Someone waiting? | Yes | No | No |
| Retries | None — caller’s job | 2, built-in | Redrive policy / mapping config |
| Error ownership | Caller | Destination or DLQ | Queue’s DLQ |
| Delivery | Exactly what the caller sends | At-least-once | At-least-once |
| Scaling shape | Spiky, caller-driven | Burst-absorbing, eventual | Gradual, backlog-driven |
| Failure page reads | “5xx rate on /api/orders” | “Events in on-failure queue” | “Queue depth / DLQ alarm” |
The mental model in three lines: someone waiting → synchronous. Nobody waiting → asynchronous. Need buffering, batching, or ordering → poll-based. And the front-door corollary: webhook → Function URL, product API → API Gateway, Lambda beside containers → ALB.
The deeper takeaway is the one that changes how you review designs: the invocation model is the architecture decision. It fixes your retry semantics, your failure routing, your scaling behavior, and your on-call experience before a single line of handler code exists. The code is just details — important details, but details. Choose the model first, on purpose, and write the function to fit it.