Nothing in a serverless estate rots as quietly as a runtime version. The code works, the dashboards are green, and then an AWS Health notice lands: the runtime your forty production functions are pinned to enters deprecation in sixty days. After that, no more security patches; later, you cannot update the function's code; later still, you cannot update its configuration either.
AWS runs this cycle continuously — Node.js 18 and Python 3.9 have already been through it, Node.js 20 and Python 3.10 are on the same conveyor belt, and whatever you deploy today has a published end date too. This post is the process we use with clients to turn runtime upgrades from an annual fire drill into a routine, low-drama pipeline step, using Serverless Framework V4.
How Lambda deprecation actually works
There are three dates, not one, and only the first is usually communicated loudly:
- End of support. The runtime stops receiving security patches and OS updates. Functions keep running and keep deploying. This is the date your security team cares about, and the one that matters for SOC 2 and HIPAA evidence.
- Block function create. You can no longer create new functions on that runtime. Existing ones are untouched. This usually bites in a surprising place: a new service in a monorepo that inherited the old
provider.runtimefrom a copy-pasted template. - Block function update. You can no longer update code or configuration. This is the one that turns a routine hotfix into an emergency migration at the worst possible moment.
The practical implication: treat end of support minus 90 days as your internal deadline, not the block-update date. By the time updates are blocked, you have lost the ability to ship the fix that the upgrade itself might require.
Step 1: Inventory what you actually run
Nobody has an accurate mental model of their runtime spread once there is more than one AWS account in play. Get the facts:
aws lambda list-functions \
--query 'Functions[].{name:FunctionName,runtime:Runtime,arch:Architectures[0]}' \
--output table
Across accounts and regions, a Config advanced query is faster:
SELECT
resourceId,
configuration.runtime,
accountId,
awsRegion
WHERE
resourceType = 'AWS::Lambda::Function'
AND configuration.runtime IN ('nodejs18.x','nodejs20.x','python3.9','python3.10')
Two things usually fall out of this inventory that nobody expected:
- Functions you do not own. Log forwarders, CloudFormation custom resources, and third-party monitoring stacks ship their own Lambdas on their own runtimes. You cannot upgrade those; you upgrade the vendor's stack version. Put them on a separate list with a vendor ticket each.
- Custom resources inside your own old stacks.
serverless-s3-sync, custom domain plugins, and log-retention plugins have historically deployed helper functions. If the plugin is unmaintained, the helper function's runtime is now your problem.
Record the inventory as a file in the repo, not a spreadsheet. It becomes the checklist you burn down.
Step 2: Set the runtime in exactly one place
The single biggest cause of upgrade pain is a runtime string scattered across function-level overrides. In Serverless Framework V4, declare it once on the provider and let every function inherit:
service: orders-api
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
memorySize: 1024
versionFunctions: true
functions:
createOrder:
handler: src/handlers/orders.create
events:
- httpApi:
path: /orders
method: post
processQueue:
handler: src/handlers/queue.process
events:
- sqs:
arn: !GetAtt OrdersQueue.Arn
Then add a guardrail so the override cannot creep back in. A grep in CI is crude and effective:
# fail the build if any function pins its own runtime
if grep -rnE '^\s{4,}runtime:' serverless.yml services/*/serverless.yml; then
echo "Function-level runtime override found. Set it on provider only."
exit 1
fi
For a Compose monorepo, hoist the value further — a shared common.yml imported by every service, or a stage parameter:
params:
default:
nodeRuntime: nodejs22.x
provider:
runtime: ${param:nodeRuntime}
Now the upgrade is a one-line diff per repository instead of a scavenger hunt.
Step 3: Make the language version match locally and in CI
A runtime upgrade that passes CI on Node 20 and then runs on Node 22 in production is not a test. Pin the version in three places and keep them in lockstep:
// package.json
{
"engines": { "node": ">=22.0.0 <23.0.0" }
}
// .nvmrc
22
# .github/workflows/deploy.yml
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
For Python, the equivalent trio is .python-version, the requires-python field in pyproject.toml, and the setup-python step. If you build dependencies with native extensions, also rebuild wheels against the new Python minor version in a container that matches the Lambda architecture — arm64 wheels compiled for 3.11 will not load on 3.13.
Serverless Framework V4 bundles with esbuild natively, so set the target explicitly rather than relying on a default:
build:
esbuild:
target: node22
minify: true
sourcemap: true
Step 4: Read the runtime's breaking changes, not just the version number
Most upgrades are boring. The ones that are not tend to break in the same handful of ways.
Node.js 18 → 20 → 22:
aws-sdkv2 is gone from the runtime image starting with Node.js 18, and v2 itself is in maintenance mode. If any handler still doesrequire('aws-sdk'), it fails at import time with a module-not-found error. Migrate to@aws-sdk/client-*v3, or bundle v2 explicitly as a short-term bridge.- The runtime now provides SDK v3, but the bundled minor version drifts. Pin your own copy in
package.jsonfor anything where behaviour matters; treat the provided one as a convenience, not a contract. - Stricter OpenSSL and TLS defaults. Legacy self-signed certificates on internal endpoints that worked on Node 16 may be rejected.
punycode,url.parse, and other legacy APIs emit deprecation warnings that pollute logs and occasionally become hard errors in a later major.
Python 3.9 → 3.11 → 3.13:
boto3andbotocoreversions in the runtime change, and so does the defaulturllib3major. Code that passedverify=or relied on retry internals can behave differently. Vendor your ownboto3if you need determinism.- Removed standard-library modules. Python 3.13 dropped a batch of long-deprecated modules (
cgi,telnetlib,distutilsand friends). A transitive dependency importing one of them fails at cold start, not at build time. - Native dependencies —
pydantic,numpy,cryptography— must be rebuilt per Python minor version and per architecture.
The cheapest way to find all of this is not reading changelogs; it is deploying to an ephemeral stage and running your integration suite against it.
Step 5: Upgrade behind a canary
Treat the runtime bump like any other risky deploy. Pick one low-traffic, well-instrumented service as the canary service, then within it use weighted aliases so a bad runtime rolls back automatically:
plugins:
- serverless-plugin-canary-deployments
functions:
createOrder:
handler: src/handlers/orders.create
deploymentSettings:
type: Linear10PercentEvery1Minute
alias: live
alarms:
- CreateOrderErrorAlarm
- CreateOrderInitDurationAlarm
Add an init-duration alarm specifically for the upgrade window. New runtimes usually improve cold start, but a bundled-dependency change can regress it sharply, and that regression is invisible in error rate:
resources:
Resources:
CreateOrderInitDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
Namespace: AWS/Lambda
MetricName: PostRuntimeExtensionsDuration
Statistic: p95
Period: 60
EvaluationPeriods: 3
Threshold: 800
ComparisonOperator: GreaterThanThreshold
Roll the remaining services in waves — canary service, then non-customer-facing async workers, then the synchronous API, then anything with Provisioned Concurrency (which needs a fresh version published and warmed before traffic shifts).
Step 6: Watch the second-order effects
A runtime upgrade changes more than the language version:
- Provisioned Concurrency must re-warm. Each new version starts cold; allow time before shifting traffic or you will serve the upgrade's worst latency to real users.
- SnapStart snapshots are invalidated and must be re-taken. If you rely on SnapStart for Python or Java, verify your priming hooks still run on the new runtime.
- Lambda layers are runtime-scoped. A layer built for
python3.10is not guaranteed to load onpython3.13; rebuild and republish layers first, then bump the functions. - Container-image functions ignore
provider.runtimeentirely. Their upgrade is a base-image bump in the Dockerfile plus a rebuild, on a separate track from your zip functions. - Extensions and agents — observability vendors, secrets caches — ship as layers and have their own compatibility matrix. Check theirs before you schedule the window.
Step 7: Make it recur on its own
The goal is that the next deprecation is uneventful. Three habits get you there:
- Subscribe the right humans to AWS Health. Route
AWS_LAMBDA_RUNTIME_DEPRECATIONevents from EventBridge into the team's ticket tracker automatically, so the notice becomes a dated work item instead of an email someone archived. - Alert on drift. A scheduled Lambda (on a current runtime, naturally) that lists functions across accounts and publishes a metric for "functions on a runtime with a published end-of-support date" gives you a single number to keep at zero.
- Upgrade on a calendar, not on a deadline. Moving to the newest supported runtime once every two quarters, as a deliberate half-day of work, is dramatically cheaper than three emergency migrations a year. It also keeps dependency upgrades small enough to reason about.
The short version
Runtime deprecation is predictable, scheduled, and therefore entirely plannable — which is exactly why it keeps catching teams out. Inventory what you run, declare the runtime in one place per repository, keep local and CI language versions in lockstep with it, read the breaking changes for your language, roll it out behind a canary with an init-duration alarm, and automate the reminder so the next one arrives as a ticket rather than a surprise.
If you would like someone else to run the inventory and the migration waves, our DevOps for Serverless Apps and Serverless Framework V4 Upgrades & Migrations teams do this regularly across multi-account estates. Get in touch and we will scope it.