Serverless Node.js (AWS Lambda)
Serverless lets you deploy individual Node.js functions that the cloud provider runs on demand, scaling from zero to thousands of concurrent executions without you managing a single server. You write a handler, the platform receives an event (an HTTP request, a queue message, a scheduled timer) and invokes your code, then bills you only for the milliseconds it ran. This page covers writing AWS Lambda handlers, taming cold starts, deploying with the Serverless Framework, reusing connections across invocations, and the trade-offs against an always-on server.
Writing a Lambda handler
A Lambda handler is an exported async function that receives an event object and a context object and returns a response. The shape of the event depends on what triggered the function; the value you return becomes the integration’s response. AWS Lambda has supported ES modules since the nodejs18.x runtime, so you can use export directly as long as your file ends in .mjs or your package.json declares "type": "module".
// handler.mjs — runtime: nodejs22.x
export const handler = async (event, context) => {
const name = event.queryStringParameters?.name ?? "world";
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: `Hello, ${name}!`, requestId: context.awsRequestId }),
};
};
Invoking this behind an API Gateway HTTP API with ?name=Ada returns:
Output:
{"message":"Hello, Ada!","requestId":"a1b2c3d4-5e6f-7890-abcd-ef1234567890"}
Never call process.exit() or leave the event loop blocked — Lambda freezes the execution environment after the response and thaws it for the next invocation. Returning (or throwing) is how you signal completion.
Cold starts
When no warm execution environment is available, Lambda must allocate one, download your code, start the Node.js runtime, and run any top-level module code before your handler executes. That first-time latency is the cold start. Subsequent invocations reuse the warm environment and skip all of it, which is why the second request to a function is dramatically faster than the first.
Node.js has one of the lowest cold-start penalties of the major runtimes, but bundle size and top-level work still matter. Keep these factors in mind:
| Factor | Effect on cold start | Mitigation |
|---|---|---|
| Deployment package size | Larger zip takes longer to fetch and unpack | Bundle and tree-shake with esbuild |
| Top-level imports | Every import runs at init time | Import only what the handler uses; lazy-load heavy SDKs |
| Memory setting | More memory also grants more CPU | Raise memory to speed init and execution |
| VPC attachment | Adds ENI setup latency | Avoid VPC unless you need private resources |
Provisioned Concurrency keeps a configured number of environments pre-warmed so latency-sensitive endpoints never pay a cold start — but you pay for that reserved capacity around the clock, which erodes the pay-per-use benefit.
Reusing connections across invocations
Because Lambda freezes and reuses the environment, anything you declare outside the handler survives between invocations on the same warm instance. This is the single most important performance pattern in serverless Node: create expensive clients (database pools, AWS SDK clients, HTTP keep-alive agents) once at module scope, not inside the handler.
// db.mjs
import { Pool } from "pg";
// Created once per execution environment, reused across invocations.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1, // one connection per warm Lambda; let RDS Proxy multiplex
idleTimeoutMillis: 30_000,
});
export const handler = async (event) => {
const { rows } = await pool.query("SELECT id, email FROM users WHERE id = $1", [event.userId]);
return { statusCode: 200, body: JSON.stringify(rows[0] ?? null) };
};
Set context.callbackWaitsForEmptyEventLoop = false if you keep open handles (like a pool) and use the older callback style, otherwise Lambda waits for the event loop to drain before responding. With many concurrent Lambdas each holding connections, a traditional database can exhaust its connection limit — front it with Amazon RDS Proxy or a serverless-native database to pool connections safely.
Deploying with the Serverless Framework
The Serverless Framework turns a declarative serverless.yml into CloudFormation, provisioning the function, its triggers, IAM role, and log groups in one command. It removes the boilerplate of wiring API Gateway and Lambda together by hand.
# serverless.yml
service: greeting-api
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
memorySize: 512
environment:
DATABASE_URL: ${env:DATABASE_URL}
functions:
greet:
handler: handler.handler
events:
- httpApi:
path: /greet
method: get
Install the CLI and deploy:
npm install --save-dev serverless serverless-esbuild
npx serverless deploy
Output:
Deploying greeting-api to stage dev (us-east-1)
✔ Service deployed to stack greeting-api-dev (84s)
endpoint: GET - https://abc123.execute-api.us-east-1.amazonaws.com/greet
functions:
greet: greeting-api-dev-greet (1.1 MB)
The serverless-esbuild plugin bundles each function into a minimal artifact, which directly shrinks cold starts. Use npx serverless invoke -f greet to test a deployed function and npx serverless remove to tear the whole stack down.
Trade-offs vs always-on servers
Serverless is not universally better than a long-running Express or Fastify server; it is a different cost and operational model. Choosing well means matching the model to your traffic shape.
| Concern | Serverless (Lambda) | Always-on server |
|---|---|---|
| Scaling | Automatic, per-request, to zero | Manual or autoscaling groups |
| Cost at low traffic | Near zero (pay per use) | Pays for idle capacity |
| Cost at sustained high traffic | Can exceed a reserved instance | Often cheaper per request |
| Latency | Cold starts on scale-up | Consistently warm |
| Long-lived connections | Hard (WebSockets need extra services) | Native |
| Execution limit | 15-minute max per invocation | Unbounded |
| Local development | Needs emulation | Just run the process |
Lambda shines for spiky, event-driven, or low-baseline workloads. A steady, high-throughput API or anything needing persistent connections, sub-millisecond latency, or runs longer than 15 minutes is usually better and cheaper on a containerized always-on server.
Best Practices
- Initialize clients, pools, and SDK objects at module scope so warm invocations reuse them instead of reconnecting.
- Bundle handlers with esbuild and import lazily to keep deployment packages small and cold starts low.
- Front relational databases with RDS Proxy (or use a serverless database) to avoid exhausting connection limits under concurrency.
- Grant each function a least-privilege IAM role rather than sharing one broad role across the service.
- Pin the runtime (
nodejs22.x) and set a sensiblememorySize— more memory buys more CPU and faster execution. - Use Provisioned Concurrency only for latency-critical endpoints where the steady cost is justified.
- Set structured logging and a timeout slightly above your expected p99 so runaway invocations fail fast instead of burning budget.