← Back to Guides

Serverless with AWS Lambda

📖 15 min read | 📅 Updated: January 2025 | 🏷️ Backend & APIs

Introduction

AWS Lambda lets you run code without provisioning servers. This guide covers Lambda functions, API Gateway, DynamoDB, event triggers, deployment strategies, and best practices for building production-ready serverless applications.

1. Lambda Function Basics

// Basic Lambda function structure
exports.handler = async (event, context) => {
    // event: input data (API request, S3 event, etc.)
    // context: runtime information
    
    console.log('Event:', JSON.stringify(event));
    console.log('Request ID:', context.requestId);
    
    try {
        // Your business logic
        const result = await processData(event);
        
        return {
            statusCode: 200,
            headers: {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*'
            },
            body: JSON.stringify(result)
        };
    } catch (error) {
        console.error('Error:', error);
        
        return {
            statusCode: 500,
            body: JSON.stringify({ error: error.message })
        };
    }
};

// TypeScript Lambda with better typing
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';

export const handler = async (
    event: APIGatewayProxyEvent,
    context: Context
): Promise => {
    // Type-safe Lambda function
    const { body, pathParameters, queryStringParameters } = event;
    
    return {
        statusCode: 200,
        body: JSON.stringify({ message: 'Success' })
    };
};

2. API Gateway Integration

// serverless.yml - Serverless Framework
service: my-api

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  environment:
    TABLE_NAME: ${self:service}-${self:provider.stage}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:Query
            - dynamodb:Scan
            - dynamodb:GetItem
            - dynamodb:PutItem
          Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.TABLE_NAME}"

functions:
  getUser:
    handler: src/handlers/getUser.handler
    events:
      - http:
          path: /users/{id}
          method: get
          cors: true
          
  createUser:
    handler: src/handlers/createUser.handler
    events:
      - http:
          path: /users
          method: post
          cors: true
          request:
            schemas:
              application/json: ${file(schemas/user-schema.json)}
              
  listUsers:
    handler: src/handlers/listUsers.handler
    events:
      - http:
          path: /users
          method: get
          cors: true
          request:
            parameters:
              querystrings:
                page: false
                limit: false

resources:
  Resources:
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.TABLE_NAME}
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

3. DynamoDB Operations

// DynamoDB client with AWS SDK v3
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({ region: 'us-east-1' });
const docClient = DynamoDBDocumentClient.from(client);

const TABLE_NAME = process.env.TABLE_NAME;

// Get single item
export async function getUser(userId: string) {
    const command = new GetCommand({
        TableName: TABLE_NAME,
        Key: { id: userId }
    });
    
    const response = await docClient.send(command);
    return response.Item;
}

// Create/Update item
export async function createUser(user: any) {
    const command = new PutCommand({
        TableName: TABLE_NAME,
        Item: {
            id: generateId(),
            ...user,
            createdAt: new Date().toISOString()
        },
        ConditionExpression: 'attribute_not_exists(id)' // Prevent overwrites
    });
    
    await docClient.send(command);
}

// Query with pagination
export async function listUsersByStatus(status: string, limit = 20, lastKey?: any) {
    const command = new QueryCommand({
        TableName: TABLE_NAME,
        IndexName: 'StatusIndex',
        KeyConditionExpression: '#status = :status',
        ExpressionAttributeNames: {
            '#status': 'status'
        },
        ExpressionAttributeValues: {
            ':status': status
        },
        Limit: limit,
        ExclusiveStartKey: lastKey
    });
    
    const response = await docClient.send(command);
    
    return {
        items: response.Items,
        lastKey: response.LastEvaluatedKey
    };
}

// Batch operations
import { BatchWriteCommand } from '@aws-sdk/lib-dynamodb';

export async function batchCreateUsers(users: any[]) {
    const batches = chunk(users, 25); // DynamoDB limit
    
    for (const batch of batches) {
        const command = new BatchWriteCommand({
            RequestItems: {
                [TABLE_NAME]: batch.map(user => ({
                    PutRequest: {
                        Item: {
                            id: generateId(),
                            ...user,
                            createdAt: new Date().toISOString()
                        }
                    }
                }))
            }
        });
        
        await docClient.send(command);
    }
}

4. Lambda Handler Examples

// GET /users/{id}
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { getUser } from '../lib/dynamodb';

export const handler = async (event: APIGatewayProxyEvent): Promise => {
    try {
        const userId = event.pathParameters?.id;
        
        if (!userId) {
            return {
                statusCode: 400,
                body: JSON.stringify({ error: 'User ID required' })
            };
        }
        
        const user = await getUser(userId);
        
        if (!user) {
            return {
                statusCode: 404,
                body: JSON.stringify({ error: 'User not found' })
            };
        }
        
        return {
            statusCode: 200,
            body: JSON.stringify(user)
        };
    } catch (error) {
        console.error('Error:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ error: 'Internal server error' })
        };
    }
};

// POST /users
import { createUser } from '../lib/dynamodb';

export const handler = async (event: APIGatewayProxyEvent): Promise => {
    try {
        if (!event.body) {
            return {
                statusCode: 400,
                body: JSON.stringify({ error: 'Request body required' })
            };
        }
        
        const data = JSON.parse(event.body);
        
        // Validation
        if (!data.email || !data.name) {
            return {
                statusCode: 400,
                body: JSON.stringify({ error: 'Email and name required' })
            };
        }
        
        const user = await createUser(data);
        
        return {
            statusCode: 201,
            body: JSON.stringify(user)
        };
    } catch (error) {
        console.error('Error:', error);
        return {
            statusCode: 500,
            body: JSON.stringify({ error: 'Internal server error' })
        };
    }
};

5. Event-Driven Architecture

// S3 trigger - Process uploaded files
export const handler = async (event: S3Event) => {
    for (const record of event.Records) {
        const bucket = record.s3.bucket.name;
        const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
        
        console.log(`Processing file: ${key} from bucket: ${bucket}`);
        
        // Get file from S3
        const s3 = new S3Client({});
        const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
        const response = await s3.send(getCommand);
        
        // Process file
        const content = await response.Body?.transformToString();
        
        // Save results
        const putCommand = new PutObjectCommand({
            Bucket: bucket,
            Key: `processed/${key}`,
            Body: processContent(content)
        });
        await s3.send(putCommand);
    }
};

// SQS trigger - Process messages
export const handler = async (event: SQSEvent) => {
    for (const record of event.Records) {
        const message = JSON.parse(record.body);
        
        try {
            await processMessage(message);
            // Message auto-deleted on success
        } catch (error) {
            console.error('Failed to process message:', error);
            // Message returned to queue for retry
            throw error;
        }
    }
};

// DynamoDB Stream - React to changes
export const handler = async (event: DynamoDBStreamEvent) => {
    for (const record of event.Records) {
        if (record.eventName === 'INSERT') {
            const newUser = unmarshall(record.dynamodb!.NewImage!);
            await sendWelcomeEmail(newUser.email);
        } else if (record.eventName === 'MODIFY') {
            const oldUser = unmarshall(record.dynamodb!.OldImage!);
            const newUser = unmarshall(record.dynamodb!.NewImage!);
            
            if (oldUser.status !== newUser.status) {
                await notifyStatusChange(newUser);
            }
        }
    }
};

// EventBridge (CloudWatch Events)
functions:
  scheduledTask:
    handler: src/handlers/scheduledTask.handler
    events:
      - schedule:
          rate: cron(0 2 * * ? *) # Every day at 2 AM
          enabled: true

6. Authentication & Authorization

// Lambda Authorizer (Custom)
export const handler = async (event: APIGatewayTokenAuthorizerEvent) => {
    try {
        const token = event.authorizationToken.replace('Bearer ', '');
        
        // Verify JWT
        const decoded = jwt.verify(token, process.env.JWT_SECRET!);
        
        // Generate policy
        return {
            principalId: decoded.userId,
            policyDocument: {
                Version: '2012-10-17',
                Statement: [
                    {
                        Action: 'execute-api:Invoke',
                        Effect: 'Allow',
                        Resource: event.methodArn
                    }
                ]
            },
            context: {
                userId: decoded.userId,
                email: decoded.email,
                role: decoded.role
            }
        };
    } catch (error) {
        throw new Error('Unauthorized');
    }
};

// Use authorizer in serverless.yml
functions:
  protectedRoute:
    handler: src/handlers/protected.handler
    events:
      - http:
          path: /protected
          method: get
          authorizer:
            name: customAuthorizer
            type: token
            identitySource: method.request.header.Authorization

// Access user info in protected function
export const handler = async (event: APIGatewayProxyEvent) => {
    const userId = event.requestContext.authorizer?.userId;
    const role = event.requestContext.authorizer?.role;
    
    // Check permissions
    if (role !== 'admin') {
        return {
            statusCode: 403,
            body: JSON.stringify({ error: 'Forbidden' })
        };
    }
    
    return {
        statusCode: 200,
        body: JSON.stringify({ message: 'Access granted', userId })
    };
};

7. Cold Start Optimization

// Minimize cold starts

// 1. Provisioned Concurrency
functions:
  criticalFunction:
    handler: src/handlers/critical.handler
    provisionedConcurrency: 2 # Keep 2 instances warm

// 2. Reuse connections outside handler
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

// Initialize outside (reused across invocations)
const client = new DynamoDBClient({
    region: process.env.AWS_REGION,
    maxAttempts: 3
});

export const handler = async (event: any) => {
    // Use client (already initialized)
    // ...
};

// 3. Reduce package size
// package.json
{
  "scripts": {
    "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js"
  }
}

// 4. Use Lambda Layers for dependencies
layers:
  commonDeps:
    path: layers/common
    name: ${self:service}-common-deps
    description: Common dependencies
    compatibleRuntimes:
      - nodejs18.x

functions:
  myFunction:
    handler: src/handler.main
    layers:
      - { Ref: CommonDepsLambdaLayer }

// 5. Lazy load heavy dependencies
export const handler = async (event: any) => {
    // Only load when needed
    if (event.requiresHeavyLib) {
        const heavyLib = await import('heavy-library');
        return heavyLib.process(event.data);
    }
    
    return simpleProcess(event.data);
};

8. Environment & Secrets

// Secrets Manager integration
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const secretsClient = new SecretsManagerClient({ region: 'us-east-1' });

// Cache secrets
let cachedSecrets: any = null;

async function getSecrets() {
    if (cachedSecrets) {
        return cachedSecrets;
    }
    
    const command = new GetSecretValueCommand({
        SecretId: process.env.SECRET_NAME
    });
    
    const response = await secretsClient.send(command);
    cachedSecrets = JSON.parse(response.SecretString!);
    
    return cachedSecrets;
}

export const handler = async (event: any) => {
    const secrets = await getSecrets();
    
    // Use secrets
    const apiKey = secrets.API_KEY;
    const dbPassword = secrets.DB_PASSWORD;
    
    // ...
};

// Parameter Store (cheaper for simple values)
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssmClient = new SSMClient({ region: 'us-east-1' });

async function getParameter(name: string) {
    const command = new GetParameterCommand({
        Name: name,
        WithDecryption: true
    });
    
    const response = await ssmClient.send(command);
    return response.Parameter?.Value;
}

9. Monitoring & Logging

// Structured logging with Lambda Powertools
import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger({
    serviceName: 'my-service',
    logLevel: 'INFO'
});

export const handler = async (event: any) => {
    logger.addContext({
        requestId: event.requestContext?.requestId
    });
    
    logger.info('Processing request', {
        userId: event.pathParameters?.userId,
        action: 'get-user'
    });
    
    try {
        const result = await processRequest(event);
        
        logger.info('Request completed', {
            duration: Date.now() - startTime,
            itemCount: result.items.length
        });
        
        return result;
    } catch (error) {
        logger.error('Request failed', {
            error: error.message,
            stack: error.stack
        });
        throw error;
    }
};

// Custom metrics with CloudWatch
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';

async function recordMetric(name: string, value: number) {
    const client = new CloudWatchClient({});
    
    const command = new PutMetricDataCommand({
        Namespace: 'MyApp',
        MetricData: [
            {
                MetricName: name,
                Value: value,
                Unit: 'Count',
                Timestamp: new Date()
            }
        ]
    });
    
    await client.send(command);
}

// X-Ray tracing
import { captureAWSv3Client } from 'aws-xray-sdk-core';

const client = captureAWSv3Client(new DynamoDBClient({}));

10. Deployment & CI/CD

# GitHub Actions deployment
name: Deploy Lambda

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Deploy with Serverless Framework
        run: npx serverless deploy --stage production

# Blue-Green deployment
functions:
  myFunction:
    handler: src/handler.main
    deploymentSettings:
      type: AllAtOnce # or Linear10PercentEvery1Minute, Canary10Percent5Minutes
      alias: Live
      
# Local testing with SAM
sam local start-api
sam local invoke MyFunction -e events/test-event.json

11. Best Practices

✓ Serverless Best Practices:

Conclusion

AWS Lambda enables building scalable applications without server management. Success requires understanding cold starts, optimizing costs, proper monitoring, and following serverless best practices. Start with simple functions, measure performance, and optimize based on real usage patterns.

💡 Pro Tip: Use AWS Lambda Power Tuning to find the optimal memory configuration for your functions. More memory = faster CPU, but costs more. The tool helps you find the sweet spot between cost and performance.