
AWS Lambda's 90-Minute Timeout Is Not a Bigger Number — It's a Different Compute Model
On September 9, 2026, AWS quietly shipped one of the more consequential Lambda changes of the year: asynchronous and event-source-mapping (ESM) invocations on Lambda Managed Instances (LMI) can now run for up to 90 minutes, a 6x jump from the 15-minute ceiling that has capped every Lambda function since 2018.
If you've spent any time architecting around that 15-minute wall — Step Functions state machines that exist purely to chain Lambda calls, Fargate tasks spun up just because a job might run long, SQS-triggered Lambdas that checkpoint progress to DynamoDB so they can pick up where they left off — this is worth fifteen minutes of your attention. Not because the number changed, but because of what changed underneath it.
What Actually Shipped
Three things, and the distinction between them matters more than the headline:
- Function timeout for async and ESM invocations on Lambda Managed Instances goes from 15 minutes to a configurable maximum of 90 minutes.
- Synchronous invocations are untouched — API Gateway, ALB, and direct
RequestResponsecalls still hit the wall at 15 minutes, full stop. - This is scoped to Lambda Managed Instances, not standard Lambda. If your functions run on the classic Firecracker microVM execution model, this change doesn't apply to you at all.
That third point is the one people skim past. LMI isn't a timeout config flag on regular Lambda — it's a separate compute model AWS introduced where your function runs on EC2-backed instances that Lambda manages for you, and multiple concurrent invocations share an instance. You get EC2-class pricing economics and access to compute configurations (larger CPU/memory ratios, specific instance families) that the standard Lambda execution environment doesn't expose. The 90-minute timeout is a benefit of that model, not a bolt-on.
If you're not already on LMI, adopting the 90-minute timeout means a migration decision, not a config change.
Why AWS Built This

The 15-minute ceiling was never really about technical impossibility — it was about the operating assumptions of the Firecracker microVM model: fast cold starts, per-invocation billing granularity, aggressive scale-to-zero. Long-running work broke those assumptions, so anything past 15 minutes got pushed toward Step Functions orchestration, Fargate, Batch, or EC2.
That pattern shows up constantly in workloads AWS calls out directly in the announcement: media transcoding, financial calculations (Monte Carlo-style simulations), AI inference batches, and general data processing pipelines. These are jobs that are bursty and event-driven — exactly Lambda's sweet spot — but also long-running, which historically wasn't.
How It Actually Works Under the Hood
The 90-minute number is downstream of how the LMI execution environment differs from the default one. Five mechanics matter, and one of them will bite you if you skip it.
The execution environment is continuous, not frozen and thawed. Standard Lambda freezes your environment between invocations and can reclaim it at any time. An LMI environment stays continuously active on EC2-backed capacity, processing invocations as they arrive without that freeze/thaw cycle. Long-running work stops being an anomaly in the model.
Multiple runtime workers share one instance. At init, the runtime spawns a configured number of runtime workers. Each concurrent invocation is routed to a different worker on the same instance — this is where LMI's throughput-per-dollar advantage over the one-invocation-per-container model comes from. If every worker is busy, new invocations are rejected as backpressure until a worker frees up.
Init is slower but far rarer. Cold initialization on LMI can take up to 15 minutes — the init timeout is the greater of 130 seconds or your configured function timeout, capped at 900 seconds. You're not booting a microVM, you're bringing up an EC2-backed environment. The tradeoff is that you pay that cost dramatically less often, because the environment doesn't scale to zero between bursts.
Timeouts do not forcibly kill your code. This is the single most important operational detail in the entire feature, and it's easy to miss. When an invocation exceeds its timeout, Lambda marks it failed and returns
Task timed out after <timeout> seconds to the caller — but your code keeps running on that worker. Nothing terminates it. You are responsible for checking the remaining time yourself and stopping.Concretely, in the reconciliation case: your code writes a settlement record to DynamoDB, the timeout fires, Lambda tells the caller the invocation failed, the async retry policy re-invokes with the same event — and meanwhile your original "failed" invocation is still running in the background and proceeds to publish an SNS notification. You now have a duplicate ledger write and duplicate notifications, with no clean way to attribute which came from the zombie run. That's why the handler below checks
context.get_remaining_time_in_millis() explicitly rather than trusting the configured timeout to enforce itself.Failures are isolated per invocation, mostly. If one invocation times out or a single runtime worker process crashes, other concurrent invocations on the same instance continue unaffected. But if an extension crashes, the entire execution environment is marked unhealthy and terminated — and unlike default Lambda, LMI makes no attempt to reset and reinitialize. Unhealthy environments are replaced, not repaired.
Retry semantics themselves are familiar: async invocations retry per your policy (default two attempts) before landing in your DLQ or on-failure destination; ESM retries follow your batch size, bisect-on-error, and max-retry configuration.
A Real Example: Overnight Risk Simulation Batch
Here's a pattern I've built variations of at G+D-adjacent fintech workloads: a nightly VaR (Value-at-Risk) Monte Carlo simulation that runs per portfolio, triggered by an S3 upload of the day's position snapshot. Before this change, a single portfolio with enough simulation paths could blow past 15 minutes, forcing one of two bad options:
- Split the job across multiple chained Lambda invocations with checkpoint state in DynamoDB, adding orchestration complexity for a problem that's fundamentally just "run longer."
- Move the whole workload to Fargate, losing the event-driven S3 trigger simplicity and taking on task-definition and cluster-capacity management for something that only runs once a night.
With a 90-minute async timeout on LMI, the job runs as a single Lambda invocation, triggered the same way, with no artificial chaining.
Architecture
1
2
3
4
5
6
7
flowchart LR
A[S3: daily position snapshot uploaded] -->|S3 Event Notification| B[Lambda - async invoke]
B --> C[Lambda Managed Instance\ntimeout: 5400s]
C --> D[Monte Carlo simulation\n50k+ paths per portfolio]
D --> E[Results written to S3 / Redshift]
C -.checkpoint every N paths.-> F[Durable Functions\ncheckpoint store]
F -.replay on failure.-> C
The important design detail: this is an async invocation (S3 event notifications are inherently async), which is exactly the invocation type this change targets. A synchronous API call asking "give me the VaR for this portfolio right now" still can't ride this — that path stays under 15 minutes, and correctly so, because nobody should hold an HTTP connection open for 90 minutes.
Terraform: Configuring the Function on LMI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
resource "aws_lambda_function" "var_simulation" {
function_name = "portfolio-var-simulation"
role = aws_iam_role.var_simulation_exec.arn
package_type = "Image"
image_uri = "${aws_ecr_repository.var_sim.repository_url}:latest"
# Lambda Managed Instances configuration
instance_configuration {
instance_type = "lmi.large" # illustrative — check current LMI instance family names
}
timeout = 5400 # 90 minutes, in seconds — max for async/ESM on LMI
memory_size = 3008
environment {
variables = {
SIMULATION_PATHS = "50000"
CHECKPOINT_INTERVAL = "5000"
RESULTS_BUCKET = aws_s3_bucket.var_results.bucket
}
}
tags = {
Workload = "risk-simulation"
Execution = "lambda-managed-instances"
}
}
# Async invocation config — separate from the sync default,
# and where retry/DLQ behavior for long-running jobs actually lives
resource "aws_lambda_function_event_invoke_config" "var_simulation_async" {
function_name = aws_lambda_function.var_simulation.function_name
maximum_retry_attempts = 1
maximum_event_age_in_seconds = 5400
destination_config {
on_failure {
destination = aws_sqs_queue.var_simulation_dlq.arn
}
on_success {
destination = aws_sns_topic.var_simulation_complete.arn
}
}
}
# S3 event notification triggers the async invocation
resource "aws_s3_bucket_notification" "position_snapshot_trigger" {
bucket = aws_s3_bucket.position_snapshots.id
lambda_function {
lambda_function_arn = aws_lambda_function.var_simulation.arn
events = ["s3:ObjectCreated:*"]
filter_suffix = ".parquet"
}
}
resource "aws_lambda_permission" "allow_s3" {
statement_id = "AllowS3Invoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.var_simulation.function_name
principal = "s3.amazonaws.com"
source_arn = aws_s3_bucket.position_snapshots.arn
}
A few things worth calling out honestly rather than glossing over:
timeout = 5400is the ceiling, not a recommendation. Set it to what your P99 run actually needs plus headroom — a runaway simulation loop now costs you 90 minutes of LMI compute instead of 15.- The
maximum_event_age_in_secondson the async invoke config should track your timeout, or you risk Lambda discarding an event before your long-running job even gets a chance to pick it up under load. - I've marked
instance_typeas illustrative — verify the current LMI instance family naming against the Lambda developer guide before you ship this; AWS has been iterating on the exact configuration surface for LMI since its release.
Python: The Handler, With Durable-Functions-Style Checkpointing
The 90-minute window is long enough that you should assume the invocation can fail partway through — Lambda's underlying infrastructure can still recycle an instance. This is exactly what Lambda durable functions exist for: checkpoint-and-replay semantics for long-running invocations, which the announcement explicitly says compose with the new timeout.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import json
import boto3
from datetime import datetime, timezone
s3 = boto3.client("s3")
CHECKPOINT_INTERVAL = 5000 # paths between checkpoints
RESULTS_BUCKET = "var-simulation-results"
def handler(event, context):
"""
Async-invoked handler. Runs up to 90 minutes on LMI.
Checkpoints progress so a mid-run interruption resumes
instead of restarting a 50k-path simulation from zero.
"""
portfolio_id = event["portfolio_id"]
total_paths = event.get("total_paths", 50000)
resume_from = event.get("resume_from_path", 0)
results = load_partial_results(portfolio_id) if resume_from else []
for path_idx in range(resume_from, total_paths):
# remaining_time_in_millis is the honest signal here —
# don't just trust the configured timeout blindly
if context.get_remaining_time_in_millis() < 60_000:
checkpoint(portfolio_id, path_idx, results)
return {
"status": "checkpointed",
"resume_from_path": path_idx,
"portfolio_id": portfolio_id,
}
results.append(run_simulation_path(portfolio_id, path_idx))
if path_idx % CHECKPOINT_INTERVAL == 0 and path_idx > 0:
checkpoint(portfolio_id, path_idx, results)
write_final_results(portfolio_id, results)
return {
"status": "complete",
"portfolio_id": portfolio_id,
"paths_run": total_paths,
"completed_at": datetime.now(timezone.utc).isoformat(),
}
def run_simulation_path(portfolio_id: str, path_idx: int) -> float:
# Placeholder for the actual Monte Carlo path calculation —
# correlated asset returns, position revaluation, P&L aggregation.
# This is the part that actually needs the 90 minutes.
...
def checkpoint(portfolio_id: str, path_idx: int, results: list) -> None:
s3.put_object(
Bucket=RESULTS_BUCKET,
Key=f"checkpoints/{portfolio_id}/latest.json",
Body=json.dumps({"resume_from_path": path_idx, "results": results}),
)
def load_partial_results(portfolio_id: str) -> list:
obj = s3.get_object(
Bucket=RESULTS_BUCKET, Key=f"checkpoints/{portfolio_id}/latest.json"
)
return json.loads(obj["Body"].read())["results"]
def write_final_results(portfolio_id: str, results: list) -> None:
s3.put_object(
Bucket=RESULTS_BUCKET,
Key=f"results/{portfolio_id}/{datetime.now(timezone.utc).date()}.json",
Body=json.dumps(results),
)
The
context.get_remaining_time_in_millis() < 60_000 check is the part people skip and then get burned by. A 90-minute budget feels generous right up until a noisy-neighbor instance or a slow correlated-asset lookup eats into it — checkpoint defensively, not only at your fixed interval.Triggering It Manually (CLI)
For testing outside the S3 trigger path:
1
2
3
4
5
aws lambda invoke \
--function-name portfolio-var-simulation \
--invocation-type Event \
--payload '{"portfolio_id": "PORT-4471", "total_paths": 50000}' \
response.json
--invocation-type Event is what makes this an async invocation — this is the flag that actually puts you on the new 90-minute path. Swap it for RequestResponse and you're back to the 15-minute synchronous ceiling regardless of what your function's configured timeout says.A Second Use Case: Nightly Settlement Reconciliation
The risk simulation above is the clean case. Here's the messier one, which shows the problem this actually solves.
A payments processor receives an end-of-day settlement file from a card network — potentially millions of transaction records — and must reconcile it against internal ledger entries before markets reopen. The job is inherently continuous: you stream through a sorted file, match records, flag exceptions, and write a reconciliation report. It doesn't shard cleanly across independent invocations, because match logic carries sequential state — running balances, carry-forward exceptions from unmatched records.
The three pre-existing options were all compromises:
| Approach | What it cost you |
|---|---|
| Chain 15-minute Lambdas with DynamoDB checkpoints | A state machine that exists solely to fight a timeout; every checkpoint boundary is a new failure mode |
| Move to Fargate | Lose the S3-trigger-on-arrival simplicity; own task definitions and cluster capacity for a job that runs once a night |
| Cron on EC2 | Lose serverless operations entirely — patching, scaling, availability all become yours again |
With a 90-minute async timeout on LMI, the file lands in S3, triggers one function, and the whole reconciliation runs as a single unit of work. The architectural workaround disappears rather than getting cheaper.
Pros
- The workaround goes away entirely — no Step Functions chain or Fargate migration existing purely to escape a 15-minute wall.
- Higher throughput per dollar on sustained load, because concurrent invocations share a warm instance instead of each paying for an isolated environment.
- Real EC2 hardware access, including Graviton, for CPU- and memory-intensive work the default execution environment doesn't expose well.
- EC2-style pricing options — Savings Plans and Reserved Instances become available, which per-invocation Lambda billing never offered.
- Composes with durable functions for checkpoint-and-replay, so a 90-minute job survives infrastructure hiccups instead of restarting from zero. A multi-step durable execution invoked asynchronously can span up to a year.
Cons
- You own timeout enforcement now. The "doesn't forcibly kill your code" behavior is a genuine operational hazard on every long-running path you write.
- Bad fit for bursty, low-volume traffic. If you aren't sustaining meaningful concurrent throughput, you're paying for capacity that sits underused — default Lambda's scale-to-zero wins.
- Real setup overhead. You're provisioning a capacity provider, an operator IAM role, instance types, and auto-scaling limits — infrastructure you previously didn't think about.
- Shared state is now your problem.
/tmp, module-level globals, and in-memory caches are shared across concurrent invocations. Code that was safe under per-invocation isolation can race or leak state under LMI. Use connection pools, not single connections; use unique temp filenames, not fixed ones. - Slower cold start when you do hit one — up to 15 minutes to bring up a fresh environment, versus standard Lambda's sub-second-to-low-second cold starts.
Where This Doesn't Help You
Being direct about the limits, because the AWS blog post won't be:
- Your API backend gets nothing here. If the workload is behind API Gateway or ALB, synchronous invocations are still capped at 15 minutes. This change doesn't touch request/response latency budgets at all.
- Standard Lambda functions get nothing here either, until or unless you migrate them onto Lambda Managed Instances. That's a real architectural decision — LMI changes the concurrency and pricing model, not just a config field — not a one-line Terraform diff.
- Cost isn't necessarily cheaper. LMI's pricing is EC2-instance-shaped rather than the classic per-100ms Lambda billing. A 90-minute job that used to force you onto Fargate might now be cheaper on LMI — or it might not, depending on your instance sizing and concurrency pattern. Model it against your actual Fargate task cost before assuming this is a win.
- Region availability tracks LMI, not Lambda. This feature is live everywhere LMI is available — which is not yet everywhere Lambda is available. Check before you design around it.
A Decision Heuristic
If you want a single question to run this through: is the workload steady enough to keep warm workers busy, and long enough that 15 minutes is genuinely the binding constraint?
- Steady or predictable throughput, compute-heavy batch/ETL/inference, jobs that genuinely need to run long → LMI is a strong fit, and the 90-minute timeout is the unlock.
- Spiky, low-volume, latency-sensitive request/response traffic → stay on default Lambda. You'll pay more for less.
And treat migration as an architecture decision, not a Terraform diff. Moving onto LMI changes your concurrency model (shared workers instead of isolated containers) and your pricing model (EC2-shaped instead of per-100ms). Model it against your actual current Fargate or Lambda spend before assuming it's a cost win.
The Actual Takeaway
The 15-minute Lambda timeout was never really a Lambda limitation — it was a Firecracker microVM limitation, and AWS solved it by building a genuinely different execution model rather than stretching the old one past its design assumptions. That's the more interesting engineering story here than "6x the timeout."
If you're currently maintaining a Step Functions chain, a DynamoDB-backed checkpoint hack, or a Fargate task whose only reason for existing is "the job runs 20 minutes," this is the point to re-evaluate — not by bumping a timeout value, but by asking whether the workload actually belongs on Lambda Managed Instances now.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article