What you'll learn

By the end of this you'll understand how AWS Lambda works and how to build with it using Node.js. You'll know how to write a Lambda handler, deploy it to AWS, configure the IAM execution role it runs under, trigger it from API Gateway, inspect its logs in CloudWatch, and think about the execution model — cold starts, statelessness, timeouts — in a way that makes Lambda behavior predictable rather than mysterious.

Who this is for

  • Node.js developers who've heard of Lambda but haven't written or deployed a function yet
  • Backend developers who want to run occasional jobs or API endpoints without managing a server
  • Anyone migrating from a traditional Express server who wants to understand which parts of that mental model transfer to Lambda and which don't

You can skip this if you're already deploying Lambda functions regularly and your questions are about performance tuning, VPC configuration, or provisioned concurrency. This tutorial covers the fundamentals you need to get to that point.

What is AWS Lambda?

Lambda is AWS's serverless compute service. You write a function, upload it, and AWS runs it in response to events — an HTTP request, a scheduled timer, an S3 upload, an SQS message. You pay only for the time the function is actually executing, rounded to the nearest millisecond.

Plain English: Lambda is a function host. You give AWS a JavaScript function; when something triggers it, AWS runs your function and hands you back the result. You never touch a server.

Simple idea: handler(event, context) is your entire application. The event contains everything about what triggered the function. The context contains metadata about the invocation. Return a value and Lambda sends it back as the response.

Prerequisites

  • An AWS account with IAM permissions to create Lambda functions and IAM roles
  • Node.js 18 or higher locally for testing
  • AWS CLI configured with your credentials
  • Basic familiarity with async/await in JavaScript or TypeScript

Setup from zero

Step 1 — Write a Lambda handler

A Lambda handler is an exported async function. The shape depends on the trigger, but for API Gateway v2 (HTTP API):

// handler.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  const name = event.queryStringParameters?.name ?? "World";

  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: `Hello, ${name}!` }),
  };
};

Install the AWS Lambda type definitions for TypeScript:

npm install --save-dev @types/aws-lambda

Step 2 — Package and deploy via the Console

For a first function, the Console is the clearest way to understand the moving parts:

  1. Zip your handler: zip -r function.zip handler.js (or compile TypeScript first)
  2. AWS Console → LambdaCreate function
  3. Choose Author from scratch
  4. Runtime: Node.js 20.x
  5. Architecture: x86_64 (arm64 is cheaper if you don't need x86 compatibility)
  6. Lambda automatically creates a basic execution role with CloudWatch Logs permissions — accept it
  7. Upload your zip under CodeUpload from.zip file
  8. Set the Handler to handler.handler (filename dot export name)

Step 3 — Test from the Console

Under the Test tab, create a test event with a sample API Gateway payload. Click Test and Lambda runs your function inline and shows you the response and logs. This is faster than deploying to API Gateway for iterating on the logic.

> Little tip: Lambda's execution environment is different from your local Node.js. Dependencies you install locally aren't available in Lambda unless you include them in the zip. Use npm ci --omit=dev && zip -r function.zip . -x ".ts" -x ".git/" to build a clean production bundle. For TypeScript, compile first (tsc) and zip the dist/ output plus node_modules.

The mental model

Lambda's execution model has three characteristics that differ from a long-running Express server: statelessness, cold starts, and concurrency.

Stateless — Lambda does not persist in-memory state between invocations. A module-level variable assigned in one invocation may or may not be present in the next (instances are reused but not guaranteed). Treat each invocation as starting from scratch. Persistent state goes in S3, DynamoDB, or another external store.

Cold starts — when Lambda scales a new instance to handle load (or the first invocation after a period of inactivity), it must initialize the Node.js runtime, import your code, and run module-level code before the handler runs. This initialization adds latency — typically 200–500ms for Node.js functions — on the first request to a fresh instance. Subsequent requests to the same instance skip initialization. This is a cold start.

Concurrency — Lambda runs one invocation per instance. If 100 requests arrive simultaneously, Lambda scales to 100 parallel instances. Each instance handles one request at a time. This is different from Express, which handles concurrent requests on one process using the event loop.

The practical implication: code outside the handler function (module-level SDK clients, database connections, config loading) runs once per instance initialization, then is reused across subsequent invocations on the same instance. Putting expensive setup outside the handler reduces per-invocation cost.

Key terms

Handler — the exported function Lambda invokes. Format: filename.exportName. A handler in handler.js exported as handler has the handler string handler.handler.

Execution role — an IAM role that Lambda assumes when running your function. It defines what AWS services your Lambda can call. If your function needs to read from S3 or write to DynamoDB, the execution role's policy must allow it.

Event — the input object passed to your handler. Shape depends on the trigger: API Gateway events have body, queryStringParameters, and headers; S3 events have Records[] with bucket and key information; SQS events have Records[] with message bodies.

Cold start — the latency added when Lambda initializes a new instance. Affects the first invocation on a fresh instance and after periods of inactivity. Subsequent requests to the same instance don't pay this cost.

Concurrency limit — by default, AWS accounts can run 1,000 Lambda instances simultaneously per region. Functions scale within this limit automatically.

Provisioned concurrency — pre-initialized instances that eliminate cold starts for latency-sensitive functions. Costs more but the first-invocation latency matches subsequent ones.

Step-by-step: connecting an API Gateway trigger

  1. In the Lambda Console → ConfigurationTriggersAdd trigger
  2. Select API GatewayCreate an APIHTTP API (simpler and cheaper than REST API)
  3. Leave security as Open for now (you'll add auth later)
  4. Click Add

Lambda shows you the API endpoint URL. Open it in a browser or curl it:

curl "https://abc123.execute-api.us-east-1.amazonaws.com/default/my-function?name=Developer"

Response: {"message":"Hello, Developer!"}

To add S3 read access to the execution role:

  1. Lambda Console → ConfigurationPermissions → click the role name
  2. IAM opens — Add permissionsAttach policies
  3. Search for and attach AmazonS3ReadOnlyAccess or add a custom inline policy

> Little tip: Put SDK client instantiation and any expensive initialization at module level, outside the handler function. Lambda reuses the same instance across multiple invocations, so module-level code runs once per cold start, not once per request. An S3Client or database connection created outside the handler is shared across all invocations on that instance — cheaper and faster.

Patterns

Structured logging — Lambda captures everything you write to console.log, console.error, etc. and sends it to CloudWatch Logs. Use structured JSON logging so logs are queryable:

console.log(JSON.stringify({
  level: "info",
  message: "Processing request",
  requestId: context.awsRequestId,
  path: event.rawPath,
}));

Early return for invalid input — validate the event at the top of the handler and return early with a 400 before any database or SDK calls:

if (!event.body) {
  return { statusCode: 400, body: JSON.stringify({ error: "Body is required" }) };
}

Separate handler from business logic — keep the handler function thin. Put business logic in separate modules that are easier to unit test locally:

// logic.ts — testable without Lambda context
export function processInput(input: string): string { ... }

// handler.ts
import { processInput } from "./logic";
export const handler = async (event) => {
  const result = processInput(event.body ?? "");
  return { statusCode: 200, body: JSON.stringify({ result }) };
};

Common mistakes

Opening a new database connection per invocation — connection setup is expensive. Create connections at module level so they're reused across invocations on the same instance. For MongoDB, initialize the Mongoose connection outside the handler and check mongoose.connection.readyState before reconnecting.

Not setting a timeout — Lambda's default timeout is 3 seconds, which is often too short for functions that call external services. Set the timeout explicitly in the function configuration. The maximum is 15 minutes; match it to what your function actually needs, not the maximum.

Returning unhandled promise rejections — if your handler throws an error without catching it, Lambda returns a function error and the invocation is retried (for asynchronous triggers like SQS and S3). Always wrap the top-level handler in a try/catch and return a structured error response for synchronous (API Gateway) triggers.

Troubleshooting

Function times out — the execution hit the configured timeout limit. Open CloudWatch Logs for the function (Lambda Console → MonitorView CloudWatch logs) and check where the log output stops. Common causes: a blocked database query, an external HTTP call that hung, or missing await on an async operation.

AccessDeniedException from SDK calls inside Lambda — the execution role doesn't have the required permission. Go to IAM → Roles → find the Lambda execution role → Permissions → add the needed policy.

Module not found error — a dependency is missing from the deployment package. The Lambda environment doesn't have your local node_modules. Rebuild the zip including node_modules (production dependencies only: npm ci --omit=dev).

Checklist

  • [ ] Handler function exported correctly and handler string matches (filename.exportName)
  • [ ] Execution role has permissions for every AWS service the function calls
  • [ ] SDK clients and DB connections initialized at module level, outside the handler
  • [ ] Timeout set appropriately for the expected execution time
  • [ ] Environment variables set in Lambda Configuration → Environment variables (not hardcoded)
  • [ ] console.log includes context.awsRequestId to correlate logs with invocations
  • [ ] Deployment package includes production node_modules for all dependencies
  • [ ] Error handling wraps the full handler in try/catch with a structured error response

Practice task

Write a Lambda function that accepts a POST request from API Gateway with a JSON body containing a text field, reverses the string, and returns the reversed string as JSON. Deploy it, trigger it via the API Gateway URL with curl, and check CloudWatch Logs to see the structured log output. Then add an environment variable for a configurable prefix string and prepend it to the reversed result. Redeploy and confirm the change without touching the API Gateway configuration.

FAQ

Is Lambda suitable for a full API, or just individual functions?

Lambda handles full APIs well. A single Lambda function behind API Gateway can route all requests using a framework like @middy/core or a mini-router inside the handler. Many production APIs run entirely on Lambda. The tradeoff is cold start latency on infrequently accessed routes; provisioned concurrency or a persistent Express server on EC2 may be preferable for latency-sensitive public APIs.

How do I handle database connections in Lambda?

For MongoDB, initialize the connection at module level with a readyState check before reconnecting. For relational databases (RDS), consider Amazon RDS Proxy — it pools connections between Lambda invocations, since Lambda can create thousands of parallel instances, each with its own connection, easily overwhelming a database's connection limit.

Can Lambda run TypeScript directly?

No — Lambda runs JavaScript. Compile TypeScript to JavaScript with tsc before packaging. Alternatively, use esbuild to bundle and transpile in one step: esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js.

What to learn next

After Lambda basics: environment variables and AWS Secrets Manager for safe secret storage, Lambda layers for shared code and large dependencies, AWS EventBridge for scheduled Lambda invocations (cron jobs), and SQS queue triggers for reliable background job processing.

  • AWS for Node apps — the SDK and credential setup Lambda functions use internally
  • IAM least privilege — designing the execution role with exactly the permissions the function needs
  • CloudWatch logging basics — querying and alerting on Lambda logs

Takeaways

Lambda runs your handler function in response to events. The execution model is stateless, concurrent, and subject to cold starts. Put initialization code at module level to amortize it across invocations. Grant permissions through the execution role, not access keys. Configure the timeout to match actual execution time. Everything else — scaling, OS patching, server management — is handled by AWS.

If you remember only one thing: Lambda is stateless by design — any in-memory state from one invocation may or may not be present in the next. Persistent state belongs in an external store like DynamoDB or S3. Build functions that work correctly whether the instance is fresh or reused, and Lambda's execution model becomes predictable.