+1 (726) 207-9872

Strangler-Fig Migration: Moving an Express Monolith to Lambda with Serverless Framework V4

Every migration engagement starts the same way: there is an Express (or Fastify, or Django) API running on EC2 or ECS, it works, and nobody is allowed to break it. The request is "move it to serverless", and the tempting answer — rewrite it as forty clean Lambda functions over a long weekend — is the one that produces a six-month project and a rollback.

The approach that actually lands is the strangler fig: put a routing layer in front of the monolith, move routes to Lambda a few at a time behind that router, and delete the old server only when nothing is routed to it any more. This post is the concrete version of that plan with Serverless Framework V4 configuration, including the parts people get wrong — sessions, background jobs, database connections, and the cutover itself.

Step 0: Inventory before you move anything

Spend a day producing four lists. Every migration that goes badly skipped this.

  1. Routes, with traffic and latency. Pull them from your access logs, not from the router file. You will usually find that 80% of traffic hits five endpoints and half the routes are dead.
  2. Everything that is not a request. Cron entries, queue workers, startup scripts, admin CLI commands. These are invisible in the route table and they are what breaks at cutover.
  3. State that lives in the process. In-memory sessions, local file writes, module-level caches, WebSocket connections, anything relying on sticky behaviour on one box.
  4. Runtime characteristics per route. Anything that runs longer than 15 minutes, streams large responses, or holds long-lived connections needs a different destination than Lambda — Fargate or Step Functions.

Score each route: low risk (stateless read, no long tail), medium (writes, third-party calls), high (long-running, stateful, or business-critical payment paths). You migrate in that order.

Step 1: Put a router in front

You need a single front door that can send some paths to the old service and some to Lambda, with no client changes. Two options:

API Gateway HTTP API with an HTTP proxy integration to the existing load balancer. Migrated routes get a Lambda integration; everything else falls through to a catch-all proxy that forwards to the monolith. This is the cleanest option when the monolith is reachable over HTTPS.

# serverless.yml — the front door
service: acme-api

provider:
  name: aws
  runtime: nodejs22.x
  region: eu-west-1
  stage: ${opt:stage, 'dev'}
  httpApi:
    payload: '2.0'

functions:
  # Migrated route: real Lambda
  getProducts:
    handler: src/products/get.handler
    events:
      - httpApi:
          path: /products
          method: GET

resources:
  Resources:
    # Everything not matched above goes to the legacy ALB
    LegacyIntegration:
      Type: AWS::ApiGatewayV2::Integration
      Properties:
        ApiId: !Ref HttpApi
        IntegrationType: HTTP_PROXY
        IntegrationMethod: ANY
        IntegrationUri: ${param:legacyAlbListenerArn}
        ConnectionType: VPC_LINK
        ConnectionId: ${param:vpcLinkId}
        PayloadFormatVersion: '1.0'
    LegacyCatchAllRoute:
      Type: AWS::ApiGatewayV2::Route
      Properties:
        ApiId: !Ref HttpApi
        RouteKey: 'ANY /{proxy+}'
        Target: !Join ['/', ['integrations', !Ref LegacyIntegration]]

CloudFront with two origins and path-based cache behaviours. Better when you also want caching, WAF, or a gradual DNS-free traffic split, and it keeps one hostname across both backends. Cache behaviours are ordered, so /products* can point at API Gateway while the default behaviour points at the ALB.

Either way: put the router in front of the unchanged monolith first and ship that on its own. Deploy it, watch the metrics for a day, confirm zero behaviour change. Now you have a safe place to move routes into.

Step 2: Lift the whole app into one Lambda (optional, but usually worth it)

Before decomposing anything, many teams get value from running the existing Express app inside a single Lambda behind the router. @codegenie/serverless-express (the maintained fork of @vendia/serverless-express) adapts an API Gateway event into a Node request object:

// src/legacy/handler.js
const serverlessExpress = require('@codegenie/serverless-express');
const app = require('../../app'); // your existing Express app, untouched

let server;
exports.handler = async (event, context) => {
  server = server ?? serverlessExpress({ app });
  return server(event, context);
};
functions:
  legacy:
    handler: src/legacy/handler.handler
    memorySize: 1024
    timeout: 29
    events:
      - httpApi: '*'

This gets you off the EC2 fleet quickly and gives you real Lambda telemetry for every route — you learn which endpoints are slow, memory-hungry, or chatty before you rewrite them. Treat it explicitly as a transitional state, not a destination: one fat function means one IAM role for everything, one deployment blast radius, a large bundle with slow cold starts, and a hard 29-second API Gateway limit. Write the decomposition dates into the plan when you ship it.

Step 3: Decompose route by route

For each route you promote out of the monolith, the work is:

Extract the handler as a pure function. The business logic should not know about Express or about Lambda. That makes it unit-testable and makes the eventual handler trivial.

// src/products/get.js — pure, testable
export async function listProducts({ repo, limit }) {
  return repo.list({ limit });
}

// src/products/get.handler.js — thin adapter
import { listProducts } from './get.js';
import { repo } from '../lib/repo.js';

export const handler = async (event) => {
  const limit = Number(event.queryStringParameters?.limit ?? 25);
  const items = await listProducts({ repo, limit });
  return { statusCode: 200, body: JSON.stringify({ items }) };
};

Give it its own IAM role. One of the real prizes of decomposition is that the products endpoint can no longer read the payments table.

functions:
  getProducts:
    handler: src/products/get.handler.handler
    iamRoleStatementsName: ${self:service}-${sls:stage}-getProducts
    iamRoleStatements:
      - Effect: Allow
        Action: [dynamodb:Query]
        Resource: !GetAtt ProductsTable.Arn

Add the explicit route in front of the catch-all. API Gateway picks the more specific route over ANY /{proxy+}, so adding GET /products silently takes that path off the monolith the moment you deploy — and deleting it puts it back. That is your rollback, and it takes one deploy.

Verify with shadow traffic first. Before flipping, run the new handler against production requests asynchronously (for reads: fire the same request at both and diff responses in a test harness) for a day. Reads are easy to shadow; writes are not, so for writes lean on staged rollout and alarms instead.

Migrate in small batches, and keep the batch size honest: two to five routes per deploy, with a day between batches on a busy system.

Step 4: The four things that actually break

Sessions in memory. Lambda has no sticky affinity. Move session state to DynamoDB (with TTL) or ElastiCache, or better, switch to signed stateless JWTs. Do this while the monolith still runs, so both backends share the same session store during the transition — otherwise users get logged out every time a request lands on the other side.

The local filesystem. Uploads written to /var/www/uploads must move to S3, ideally with presigned uploads so files never pass through your compute. /tmp on Lambda is ephemeral and per-environment; it is a scratch space, not storage.

Background jobs and cron. A node worker.js process or a crontab line has to become an explicit event source: EventBridge Scheduler or a schedule event for time-based work, SQS with a Lambda event source mapping for queue consumers, Step Functions for anything with retries and multi-step state. Migrate these before you switch off the old host, and make sure the job cannot run in both places at once — an idempotency key or a conditional write is cheap insurance.

Database connections. A long-lived pool of 20 connections becomes one connection per concurrent execution, and 500 concurrent Lambdas will exhaust Postgres. Put RDS Proxy in front of RDS or Aurora, create the client at module scope, and set a reservedConcurrency ceiling on the functions that talk to the database.

functions:
  getOrders:
    handler: src/orders/get.handler
    reservedConcurrency: 50   # a hard ceiling on DB connections
    vpc:
      securityGroupIds: [${param:lambdaSgId}]
      subnetIds: ${param:privateSubnetIds}

Keep the database exactly where it is for the whole migration. Changing compute and data store at the same time removes your ability to tell which change caused a regression.

Step 5: Cutover and decommission

When the catch-all route serves less than a few percent of traffic:

  1. Move the remaining routes, or make a deliberate decision to keep them on Fargate (long-running exports and report generation are a legitimate reason).
  2. Scale the old service down, but do not delete it, for one full business cycle — including month-end, which is when the forgotten billing cron fires.
  3. Remove the catch-all route and the VPC link.
  4. Check the old host's logs are genuinely empty before terminating. Every migration finds at least one internal consumer calling the ALB directly, bypassing your router.
  5. Delete the EC2/ECS resources and the security groups, and update the runbooks.

Then do the post-migration pass the business will ask about: per-function memory right-sizing, cold-start review on the user-facing paths, and a cost comparison against the old instance bill.

A realistic timeline

For a mid-sized Express API — roughly 60 routes, one Postgres database, a handful of cron jobs — the shape we see is: one week of inventory and the router, one week for the lift-and-shift Lambda and the session/file/job fixes, then six to ten weeks of route batches running alongside normal feature work, and a week of decommissioning. Nothing dramatic happens on any single day, which is the entire point.

If you want that plan built and executed against your codebase, our Migration to Serverless Infrastructure and Serverless Architecture Design services do exactly this. Get in touch with your route inventory and we will tell you honestly which parts should not move.