Skip to content
Express.js ex deployment 4 min read

Serverless Express

Serverless platforms like AWS Lambda, Google Cloud Functions, and Azure Functions run your code in short-lived, event-driven invocations instead of a long-running process listening on a port. Express, by contrast, is built around the Node.js http server and a persistent app.listen(). To bridge the two worlds you wrap your existing Express app in an adapter that translates the platform’s event payload into a request the app understands and converts the response back into the format the platform expects. This lets you reuse your entire Express codebase — routers, middleware, and handlers — without rewriting it for each function.

How the adapter works

In a normal deployment, Express owns the network socket: app.listen(3000) creates an HTTP server and Express handles incoming requests. On Lambda there is no socket and no long-lived server. Instead, API Gateway (or a Function URL) hands Lambda a JSON event describing the HTTP request, and your function must return a JSON response.

The serverless-http package is the de facto adapter. It takes your Express app and returns an async handler that maps the incoming event onto Express’s request/response objects, runs the full middleware chain, and serializes the result.

npm install express serverless-http
// app.js — your normal Express app, unchanged
const express = require('express');

const app = express();
app.use(express.json());

app.get('/users/:id', async (req, res) => {
  const user = await fetchUser(req.params.id);
  res.json(user);
});

module.exports = app;
// handler.js — the Lambda entry point
const serverless = require('serverless-http');
const app = require('./app');

module.exports.handler = serverless(app);

A critical detail: do not call app.listen() in the serverless build. There is no port to bind, and calling listen wastes resources and can hang the invocation. Keep app.listen() in a separate local-dev entry file so you can still run the app conventionally on your machine.

// local.js — only used for local development
const app = require('./app');
app.listen(3000, () => console.log('Dev server on http://localhost:3000'));

Request and response shape

When API Gateway invokes the handler, serverless-http receives an event and returns a structured response. A successful GET /users/42 produces output like this:

Output:

{
  "statusCode": 200,
  "headers": { "content-type": "application/json; charset=utf-8" },
  "body": "{\"id\":\"42\",\"name\":\"Ada\"}",
  "isBase64Encoded": false
}

If you serve binary content (images, PDFs), enable Base64 handling so API Gateway decodes the body correctly:

module.exports.handler = serverless(app, {
  binary: ['image/*', 'application/pdf'],
});

Cold starts

A cold start happens when the platform spins up a fresh execution environment because none is warm. The Node.js runtime boots, your module graph is loaded, and top-level code runs before the first request is served. After that the environment is reused (a warm start) for subsequent requests until it is recycled.

You cannot eliminate cold starts, but you can shrink them:

  • Move expensive setup (DB pools, AWS SDK clients) to module scope so it runs once per environment and is reused across warm invocations — never inside the handler.
  • Keep the deployment bundle small; bundlers like esbuild reduce both package size and parse time.
  • Reuse database connections rather than opening one per request, and prefer HTTP-based or serverless-native data stores that tolerate frequent reconnects.
  • For latency-sensitive APIs, use provisioned concurrency (AWS) or minimum instances (GCP/Azure) to keep environments warm at a fixed cost.
// Initialize once, reuse across warm invocations
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const ddb = new DynamoDBClient({}); // module scope = created once

const app = require('./app');
module.exports.handler = serverless(app);

Avoid creating a new database connection per invocation. With many concurrent cold starts you can exhaust connection limits on traditional databases — use a connection proxy (e.g. RDS Proxy) or a serverless-friendly driver.

Tradeoffs versus an always-on server

Serverless is not strictly better or worse — it shifts where the costs and constraints live.

ConcernServerless (Lambda)Always-on server
ScalingAutomatic, per-requestManual or autoscaling groups
Idle costNear zeroPay for running instances
Cold startsYes, adds latencyNone after startup
Long-running workCapped (e.g. 15 min)Unbounded
WebSockets / SSELimited / special handlingNative
Background jobs / cronNeeds separate triggersIn-process schedulers work
State between requestsEphemeral, not guaranteedIn-memory caches persist

Serverless shines for spiky, request-driven workloads with unpredictable traffic. An always-on server (behind PM2 or in Docker) is usually a better fit for steady traffic, persistent connections, or in-process background work.

Express 5.x changes async error handling so rejected promises in handlers propagate automatically. Pin and test your Express version against your adapter, since serverless-http relies on the standard req/res lifecycle.

Best practices

  • Keep app.listen() out of the serverless handler; use a separate file for local development.
  • Initialize clients, pools, and config at module scope so warm invocations reuse them.
  • Bundle and tree-shake your function to minimize cold-start parse time and package size.
  • Centralize error handling in an Express error middleware so failures return proper status codes instead of opaque 502s.
  • Set a sensible function timeout and memory size; more memory also raises CPU, which can lower cold-start time.
  • Use provisioned/minimum concurrency only where latency matters, since it reverses the cost savings.
  • Prefer serverless-friendly data access (connection proxies, HTTP APIs) to avoid exhausting database connections under concurrency.
Last updated June 14, 2026
Was this helpful?