Skip to main content

Ξ» AWS Lambda Function Development

AWS Lambda is the most widely used Function-as-a-Service platform in the world. In this lesson you'll write your first handler, understand the execution environment that makes Lambda fast (and occasionally slow), tune memory for cost and speed, wire up event triggers, lock functions down with IAM, and deploy with modern tooling.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write a Lambda handler that receives an event and context and returns a response
  • Explain the init vs. invoke phases and use execution-context reuse to your advantage
  • Choose a memory setting understanding that it also scales CPU β€” and cost
  • Connect functions to event sources (API Gateway, S3, DynamoDB Streams, SQS) across the sync, async, and stream invocation models
  • Apply least-privilege IAM and use environment variables safely
  • Deploy a function with the AWS CLI and with AWS SAM

Estimated Time: 40–55 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a CRUD "notes" function backed by DynamoDB and deploy it with SAM.

In This Lesson

What Is AWS Lambda?

AWS Lambda runs your code in response to events without any server for you to provision or manage. You upload a function, tell Lambda what should trigger it, and Lambda handles running it β€” from a handful of calls a day to thousands per second β€” billing you only for the milliseconds it actually executes.

πŸ’‘ Analogy β€” an on-demand chef. Running your own server is like keeping a restaurant kitchen staffed around the clock: you pay for the space, the ovens, and the cooks whether or not anyone orders. Lambda is a delivery service with on-demand chefs: a chef (a function instance) appears the moment an order (an event) arrives, cooks in isolation, and clocks out when done. Ten orders at once? Ten chefs. No orders? No bill.
graph TD A[AWS Lambda] --> B[Function] A --> C[Triggers] A --> D[Execution env] A --> E[Permissions] B --> B1[Handler] B --> B2[Runtime] B --> B3[Memory / CPU] B --> B4[Timeout] C --> C1[API Gateway] C --> C2[S3 events] C --> C3[DynamoDB Streams] C --> C4[SQS / SNS / EventBridge] E --> E1[IAM execution role] E --> E2[Resource policies]

The Handler Model

Every Lambda function has a handler: the entry point Lambda calls with two arguments β€” the event (the payload describing what triggered the function) and the context (runtime metadata like the request ID and remaining time). Here is a modern handler using ES modules on a current Node.js runtime:

// index.mjs  β€” deployed on the nodejs20.x runtime
export const handler = async (event, context) => {
  console.log('event:', JSON.stringify(event));
  console.log('requestId:', context.awsRequestId);

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Hello from Lambda!' }),
  };
};

The same idea in Python:

import json

def lambda_handler(event, context):
    print("event:", json.dumps(event))
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"message": "Hello from Lambda!"}),
    }

πŸ“– Key Terms

Runtime: the language environment Lambda provides (e.g. nodejs20.x, python3.12). You can also ship a custom runtime or a container image.

Handler string: tells Lambda which function to call, in the form file.exportedName β€” e.g. index.handler.

Timeout: the maximum wall-clock time an invocation may run (default 3s, max 15 min).

⚠️ Modernisation note

Older tutorials use require('aws-sdk') (SDK v2) and call .promise() on every operation. On Node.js 18+ runtimes the built-in SDK is v3, which is modular and already promise-based. Prefer import { DynamoDBClient } from '@aws-sdk/client-dynamodb' β€” that's what the examples below use.

The Execution Environment

Understanding how Lambda runs your code is the difference between a snappy function and a sluggish one. Each invocation goes through two phases:

  • Init phase β€” Lambda creates the execution environment, loads your code, and runs everything outside the handler (imports, client construction, top-level code). This happens on a cold start.
  • Invoke phase β€” Lambda runs the handler itself. On a warm invocation the environment already exists, so only this phase runs.
sequenceDiagram participant C as Client participant L as Lambda Note over L: Cold start (init + invoke) C->>L: Invoke L->>L: Import deps, build clients (init) L->>L: Run handler (invoke) L-->>C: Response Note over L: Warm invocation (invoke only) C->>L: Invoke again L->>L: Run handler (env reused) L-->>C: Response

Because warm environments are reused, anything you set up outside the handler survives between invocations. Do expensive work β€” building SDK clients, opening connection pools, loading config β€” once at the module top level:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';

// Built ONCE during init and reused by every warm invocation
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;

export const handler = async (event, context) => {
  console.log('remaining ms:', context.getRemainingTimeInMillis());

  const { Items } = await ddb.send(new QueryCommand({
    TableName: TABLE,
    KeyConditionExpression: 'id = :id',
    ExpressionAttributeValues: { ':id': event.id },
  }));

  return { statusCode: 200, body: JSON.stringify(Items) };
};

πŸ’‘ The rule of thumb

Put reusable, stateless setup outside the handler (clients, config). Put per-request logic inside. Never cache user-specific data outside the handler β€” a warm container may serve a different user next.

Memory, CPU & Cost

Lambda has a single performance dial: memory. Crucially, CPU is allocated proportionally to memory β€” more memory means a faster vCPU, not just more RAM. That produces a counter-intuitive result: bumping memory up can make a function both faster and cheaper, because it finishes in far less billed time.

Memory versus duration trade-off A curve showing execution duration falling steeply as allocated memory rises from 128 to 512 MB, then flattening, illustrating diminishing returns. Allocated memory (MB) β†’ Duration (ms) β†’ 128 MB Β· 350ms 256 MB Β· 180ms 512 MB Β· 120ms (sweet spot) 2048 MB Β· 60ms
Figure 1 β€” Duration drops sharply from 128 β†’ 512 MB, then flattens. The best value often sits at the knee of the curve, not the minimum memory. Measure your own function with a tool like AWS Lambda Power Tuning.

πŸ’‘ Analogy β€” choosing a delivery vehicle

128 MB is a bicycle (cheap, slow); 512 MB a scooter (a good all-rounder); 1024 MB a compact car; 3008 MB+ a van for heavy loads. The right choice depends on the size of the job β€” over-buying wastes money, under-buying wastes time.

Event Sources & Triggers

Lambda functions are invoked in three models, each with different retry and response behaviour.

Synchronous β€” the caller waits

Used by API Gateway, Application Load Balancer, and direct SDK calls. The caller blocks until your function returns, and your return value is the response. A single handler can route by HTTP method:

export const handler = async (event) => {
  const { httpMethod, pathParameters, body } = event;
  const id = pathParameters?.id;

  let result;
  switch (httpMethod) {
    case 'GET':    result = id ? await getItem(id) : await listItems(); break;
    case 'POST':   result = await createItem(JSON.parse(body)); break;
    case 'PUT':    result = await updateItem(id, JSON.parse(body)); break;
    case 'DELETE': result = await deleteItem(id); break;
    default:
      return { statusCode: 405, body: JSON.stringify({ message: 'Method not allowed' }) };
  }

  return {
    statusCode: result.statusCode,
    headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
    body: JSON.stringify(result.body),
  };
};

Asynchronous β€” fire and forget

Used by S3 events, SNS, and EventBridge. Lambda queues the event, returns immediately to the caller, and retries automatically on failure (typically twice), routing exhausted events to a Dead Letter Queue if configured. A thrown error here signals "retry me":

// Triggered when a new object lands in an S3 bucket
export const handler = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));

    if (/\.(jpe?g|png)$/i.test(key)) {
      await processImage(bucket, key);
    } else {
      console.log(`Skipping unsupported file: ${key}`);
    }
  }
  // Throwing here would let Lambda retry the whole event
};

Stream / poll-based β€” batches of records

Used by DynamoDB Streams, Kinesis, and SQS. Lambda polls the source and delivers batches of records. Your handler loops over event.Records:

import { unmarshall } from '@aws-sdk/util-dynamodb';

export const handler = async (event) => {
  for (const record of event.Records) {
    const type = record.eventName; // INSERT | MODIFY | REMOVE
    const newImage = record.dynamodb.NewImage ? unmarshall(record.dynamodb.NewImage) : null;

    if (type === 'INSERT') await onInsert(newImage);
    if (type === 'MODIFY') await onUpdate(newImage);
    if (type === 'REMOVE') await onDelete(record.dynamodb.Keys);
  }
  return { processed: event.Records.length };
};
ModelExamplesRetries?Response used?
SynchronousAPI Gateway, ALB, SDKCaller's jobYes β€” it's the HTTP response
AsynchronousS3, SNS, EventBridgeYes (auto, then DLQ)No
Stream / pollDynamoDB Streams, Kinesis, SQSYes (batch)No

IAM & Configuration

Execution roles & least privilege

Every function runs with an IAM execution role that defines exactly which AWS resources it may touch. Grant only what the function needs β€” nothing more. This policy lets a function write logs and read/write one specific DynamoDB table:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Notes"
    }
  ]
}

βœ… IAM best practices

  • Least privilege: scope actions and resources as tightly as possible.
  • One role per function: don't share a broad role across many functions.
  • Audit regularly: remove permissions the function no longer uses.

Environment variables & secrets

Use environment variables for configuration β€” table names, feature flags, endpoints β€” and give them sensible defaults. Do not store secrets in plain environment variables; fetch them from AWS Secrets Manager or SSM Parameter Store instead.

const stage = process.env.STAGE ?? 'dev';
const table = process.env.TABLE_NAME;        // config β€” fine as an env var
// const dbPassword = process.env.DB_PASSWORD;  // ❌ don't do this for secrets

⚠️ VPC access has a cost

Attaching a function to a VPC (to reach a private RDS database, say) is sometimes necessary, but historically added cold-start latency and requires a NAT Gateway for outbound internet. Only put a function in a VPC when it genuinely needs private resources.

Deploying a Function

Quick path β€” the AWS CLI

For a single function, zip the code and create it directly:

# Package the code
zip function.zip index.mjs

# Create the function
aws lambda create-function \
  --function-name hello-notes \
  --runtime nodejs20.x \
  --role arn:aws:iam::123456789012:role/lambda-notes-role \
  --handler index.handler \
  --zip-file fileb://function.zip \
  --timeout 10 \
  --memory-size 256

# Later, ship a code change
aws lambda update-function-code \
  --function-name hello-notes \
  --zip-file fileb://function.zip

Repeatable path β€” AWS SAM

Clicking around the console doesn't scale. AWS SAM (Serverless Application Model) is infrastructure-as-code: one template declares the function, its trigger, its permissions, and the table it uses. This is how real projects ship.

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Resources:
  NotesFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: ./src/
      Handler: index.handler
      Runtime: nodejs20.x
      MemorySize: 256
      Timeout: 10
      Environment:
        Variables:
          TABLE_NAME: !Ref NotesTable
      Policies:
        - DynamoDBCrudPolicy:            # scoped managed policy β€” least privilege
            TableName: !Ref NotesTable
      Events:
        Api:
          Type: Api
          Properties:
            Path: /notes
            Method: get

  NotesTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: id
        Type: String

Outputs:
  ApiUrl:
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/notes/"
# Build, then deploy interactively the first time
sam build
sam deploy --guided

Hands-on Exercise

πŸ‹οΈ Build a "Create Note" Function

Objective: Write a Lambda handler that validates input, generates an ID, and stores a note in DynamoDB β€” applying the init/invoke and least-privilege lessons.

Requirements

  1. Build the DynamoDB client once, outside the handler.
  2. Parse and validate the request body; reject a missing title with a 400.
  3. Generate a UUID, add createdAt, and put the item in the table named by process.env.TABLE_NAME.
  4. Return 201 with the created note; return 500 on unexpected errors.
πŸ’‘ Hint

Use @aws-sdk/lib-dynamodb's PutCommand so you can pass a plain JS object without marshalling. Node 20 has a built-in crypto.randomUUID() β€” no extra dependency needed.

βœ… Example solution
import { randomUUID } from 'node:crypto';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

// init phase β€” reused by warm invocations
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;

export const handler = async (event) => {
  try {
    const body = JSON.parse(event.body ?? '{}');
    if (!body.title || typeof body.title !== 'string') {
      return { statusCode: 400, body: JSON.stringify({ message: 'title is required' }) };
    }

    const now = new Date().toISOString();
    const note = {
      id: randomUUID(),
      title: body.title,
      content: body.content ?? '',
      completed: false,
      createdAt: now,
      updatedAt: now,
    };

    await ddb.send(new PutCommand({ TableName: TABLE, Item: note }));

    return { statusCode: 201, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note) };
  } catch (err) {
    console.error('createNote failed:', err);
    return { statusCode: 500, body: JSON.stringify({ message: 'Could not create note' }) };
  }
};

Deploy it by adding a POST /notes event to the SAM template from the previous section, then sam build && sam deploy.

Quiz

🎯 Check Your Understanding

Question 1: Why should you construct SDK clients outside the handler function?

Question 2: Increasing a Lambda function's memory setting also…

Question 3: A function triggered asynchronously by an S3 event throws an error. What happens?

Summary & Next Steps

πŸŽ‰ Key Takeaways

  • A Lambda handler receives event + context and returns a response.
  • The environment has an init phase (cold start, reused when warm) and an invoke phase β€” put reusable setup outside the handler.
  • Memory scales CPU, so more memory can be faster and cheaper; tune to the knee of the curve.
  • Triggers come in synchronous, asynchronous, and stream models with different retry semantics.
  • Use least-privilege IAM roles, keep secrets in Secrets Manager/SSM, and deploy with SAM for repeatability.

πŸ“š Further Reading

πŸš€ What's Next?

Writing functions one at a time gets tedious fast. Next, in Building APIs with the Serverless Framework, you'll wire many functions, a database, and an API together from a single config file β€” and deploy the whole stack with one command.