Introduction


The Serverless Framework provides a unified experience for deploying functions, APIs, and event-driven architectures across major cloud providers. While serverless eliminates infrastructure management, it introduces challenges around cold starts, observability, and cost control. This guide walks through taking a serverless application from development to production using the Serverless Framework on AWS Lambda.


Project Setup and Structure


A well-structured serverless project separates concerns across functions, layers, and configuration:



# serverless.yml

service: order-processor

frameworkVersion: "4"



provider:

  name: aws

  runtime: nodejs20.x

  region: us-east-1

  stage: ${opt:stage, 'dev'}

  environment:

    ORDER_TABLE: ${self:custom.tableName}

    QUEUE_URL: !Ref OrderQueue



plugins:

  - serverless-webpack

  - serverless-offline

  - serverless-prune-plugin



custom:

  tableName: orders-${self:provider.stage}

  webpack:

    packager: pnpm

    excludeFiles: src/**/*.test.ts

  prune:

    automatic: true

    number: 3



functions:

  createOrder:

    handler: src/handlers/createOrder.handler

    events:

      - httpApi:

          method: POST

          path: /orders

    timeout: 10

    memorySize: 256

    iamRoleStatements:

      - Effect: Allow

        Action: dynamodb:PutItem

        Resource: !GetAtt OrdersTable.Arn


Infrastructure as Code


Define resources alongside functions for self-documenting infrastructure:



resources:

  Resources:

    OrdersTable:

      Type: AWS::DynamoDB::Table

      Properties:

        TableName: orders-${self:provider.stage}

        BillingMode: PAY_PER_REQUEST

        AttributeDefinitions:

          - AttributeName: orderId

            AttributeType: S

          - AttributeName: status

            AttributeType: S

        KeySchema:

          - AttributeName: orderId

            KeyType: HASH

        GlobalSecondaryIndexes:

          - IndexName: StatusIndex

            KeySchema:

              - AttributeName: status

                KeyType: HASH

            Projection:

              ProjectionType: ALL

    OrderQueue:

      Type: AWS::SQS::Queue

      Properties:

        QueueName: orders-${self:provider.stage}

        VisibilityTimeout: 60

        RedrivePolicy:

          deadLetterTargetArn: !GetAtt DeadLetterQueue.Arn

          maxReceiveCount: 3

    DeadLetterQueue:

      Type: AWS::SQS::Queue

      Properties:

        QueueName: orders-dlq-${self:provider.stage}


Lambda Handler Implementation


Write handlers with proper error handling and observability:



// src/handlers/createOrder.ts

import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";

import { randomUUID } from "crypto";

import { Logger } from "@aws-lambda-powertools/logger";

import { Metrics } from "@aws-lambda-powertools/metrics";

import { Tracer } from "@aws-lambda-powertools/tracer";



const logger = new Logger({ serviceName: "order-processor" });

const metrics = new Metrics({ namespace: "OrderProcessor" });

const tracer = new Tracer({ serviceName: "order-processor" });



const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));



export const handler = async (

  event: APIGatewayProxyEvent

): Promise<APIGatewayProxyResult> => {

  try {

    const body = JSON.parse(event.body || "{}");

    const orderId = randomUUID();

    const order = {

      orderId,

      ...body,

      status: "PENDING",

      createdAt: new Date().toISOString(),

    };



    await ddb.send(new PutCommand({

      TableName: process.env.ORDER_TABLE,

      Item: order,

    }));



    metrics.addMetric("OrderCreated", 1, "Count");

    logger.info("Order created", { orderId });



    return {

      statusCode: 201,

      headers: { "Content-Type": "application/json" },

      body: JSON.stringify({ orderId, status: "PENDING" }),

    };

  } catch (error) {

    logger.error("Failed to create order", { error });

    metrics.addMetric("OrderCreationError", 1, "Count");

    return {

      statusCode: 500,

      body: JSON.stringify({ message: "Internal server error" }),

    };

  }

};


Local Development


The `serverless-offline` plugin provides a local Lambda emulator:



# Start local API Gateway emulator

serverless offline --stage dev --httpPort 4000



# Invoke a function directly

serverless invoke local --function createOrder \

  --path test/fixtures/create-order.json



# Run with warm container simulation

serverless offline --stage dev \

  --noPrependStageInUrl \

  --reloadHandler


Cold Start Optimization


Cold starts add latency when Lambda scales up a new execution environment:



# Optimize for cold starts

provider:

  # Use AWS Graviton for better price/performance

  architecture: arm64

  # Increase memory speeds up CPU allocation

  # (and proportionally reduces cold start time)

  memorySize: 1024



functions:

  latencyCritical:

    handler: src/handlers/critical.handler

    # Provisioned concurrency for critical paths

    provisionedConcurrency: 5

    # Reserve concurrency to prevent throttling

    reservedConcurrency: 20


Code-level optimizations:



// Cold start optimization techniques



// 1. Lazy initialization outside handler (reused across invocations)

let client: DynamoDBDocumentClient;

function getClient(): DynamoDBDocumentClient {

  if (!client) {

    client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

  }

  return client;

}



// 2. Bundle and tree-shake dependencies

// Use esbuild or webpack to exclude unused SDK clients



// 3. Minimize deployment package size

// Exclude in webpack:

// externals: ["@aws-sdk/client-dynamodb"]



// 4. Use AWS SDK v3 for modular imports

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

// Instead of: import { DynamoDB } from "aws-sdk";


Monitoring and Observability


CloudWatch is the default, but Powertools enhances observability significantly:



functions:

  processOrder:

    handler: src/handlers/processOrder.handler

    environment:

      POWERTOOLS_SERVICE_NAME: order-processor

      POWERTOOLS_METRICS_NAMESPACE: OrderProcessor

      LOG_LEVEL: INFO

    events:

      - sqs:

          arn: !GetAtt OrderQueue.Arn

          batchSize: 10

          maximumBatchingWindowInSeconds: 5


Create CloudWatch dashboards and alarms:



resources:

  Resources:

    OrderErrorAlarm:

      Type: AWS::CloudWatch::Alarm

      Properties:

        AlarmName: order-processor-errors-${self:provider.stage}

        MetricName: Errors

        Namespace: AWS/Lambda

        Dimensions:

          - Name: FunctionName

            Value: !Ref ProcessOrderLambdaFunction

        Statistic: Sum

        Period: 300

        EvaluationPeriods: 1

        Threshold: 5

        ComparisonOperator: GreaterThanThreshold


Cost Analysis


Serverless costs are driven by invocation count, duration, and memory allocation:



# Calculate monthly cost estimate

# Invocations: 10M/month

# Avg duration: 200ms

# Memory: 1024MB

# Cost = 10M * (0.2s / 1000ms) * 1GB * $0.00001667/GB-second

#      = 10M * 0.2 * 0.00001667

#      = ~$33.34/month


Optimize cost with `serverless-prune-plugin` to remove old versions:



custom:

  prune:

    automatic: true

    number: 3  # Keep only 3 latest versions


For high-throughput workloads, compare Lambda cost against ECS Fargate at sustained traffic levels. Lambda often wins for variable, low-volume traffic but becomes expensive at steady-state high throughput. Use these patterns to build production-grade serverless applications that are cost-effective, observable, and performant from day one.