+1 (726) 207-9872

Surviving Lambda Runtime Deprecations: Node.js and Python Upgrade Playbook for Serverless Framework V4

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:

  1. 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.
  2. 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.runtime from a copy-pasted template.
  3. 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-sdk v2 is gone from the runtime image starting with Node.js 18, and v2 itself is in maintenance mode. If any handler still does require('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.json for 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:

  • boto3 and botocore versions in the runtime change, and so does the default urllib3 major. Code that passed verify= or relied on retry internals can behave differently. Vendor your own boto3 if you need determinism.
  • Removed standard-library modules. Python 3.13 dropped a batch of long-deprecated modules (cgi, telnetlib, distutils and 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.10 is not guaranteed to load on python3.13; rebuild and republish layers first, then bump the functions.
  • Container-image functions ignore provider.runtime entirely. 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:

  1. Subscribe the right humans to AWS Health. Route AWS_LAMBDA_RUNTIME_DEPRECATION events from EventBridge into the team's ticket tracker automatically, so the notice becomes a dated work item instead of an email someone archived.
  2. 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.
  3. 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.