Cloud10 min read

Debugging Serverless Functions Locally: A Practical Approach

Local debugging of serverless functions requires different tools and mindset than traditional apps. Here's how I approach it with real examples.

serverlessAWS Lambdadebugginglocal developmentSAMcloud

Serverless functions are great until they break. And they will break — often in ways that are hard to reproduce locally. The typical feedback loop of `git push && wait for CI && check CloudWatch logs` is painfully slow. I've been there: debugging a cold start issue that only happened when the function hadn't been invoked in 30 minutes, or a DynamoDB permission error that didn't show up until the function hit production.

The good news: you can replicate most of the serverless environment on your laptop. It's not perfect, but with the right tools and mindset, you can catch 90% of bugs before they reach production. This post covers my go-to approach for local debugging of serverless functions, with a real example of a bug that took me hours to find.

Choosing Your Weapon: SAM vs. Alternatives

AWS SAM CLI is the most widely used tool for local development of Lambda functions. It emulates the Lambda runtime and API Gateway locally using Docker. Alternatives include `serverless-offline` (for Serverless Framework), `docker-lambda`, and `LocalStack`. I use SAM because it integrates well with CloudFormation templates and supports step-through debugging.

Whichever you choose, the goal is the same: invoke your function with a realistic event and inspect the result. The key is to make the local environment as close to production as possible.

Basic SAM workflow — build and invoke a Lambda function locally.
# Install SAM CLI
brew install aws-sam-cli

# Initialize a new project
sam init --runtime python3.9 --name my-function

# Build and invoke locally
sam build
sam local invoke HelloWorldFunction -e events/event.json

Emulating Dependencies: DynamoDB Local and LocalStack

Many serverless functions depend on other AWS services. You can't (and shouldn't) point your local function at production DynamoDB tables. Instead, use local emulators. For DynamoDB, Amazon provides a downloadable JAR. I run it in Docker: `docker run -p 8000:8000 amazon/dynamodb-local`.

Then, in your SAM `template.yaml`, set the DynamoDB endpoint via environment variables. For example, for a Node.js function, you'd set `DYNAMODB_ENDPOINT=http://localhost:8000`. This way, your function code remains unchanged — it just uses a different endpoint.

warning

Emulators are not perfect. DynamoDB Local doesn't support TTL or auto-scaling. LocalStack might not support every API. Always test against the real service before deploying to production.

The Cold Start Bug That Cost Me a Day

  1. 09:00Deployed a new Lambda function that processes S3 events.
  2. 10:30User reports that some uploads are not being processed.
  3. 11:00Check CloudWatch — no errors, but some invocations are missing.
  4. 13:00Reproduce locally: function works every time. Start suspecting cold start.
  5. 15:00Realize that on cold start, the function initializes a global HTTP client with a timeout of 2 seconds. The first request to the external API is slow, causing timeout. Subsequent invocations reuse the client and work fine.
  6. 16:00Fix: increase timeout and add retry logic. Deploy. All good.

Lesson

Local invocations usually don't have cold start delays because the container is reused. To test cold starts, force a new container with `sam local invoke --container-reuse Never`. Also, simulate network latency if your function calls external APIs.

Setting Up a Fast Iteration Loop

The fastest way to debug is to have a hot-reload loop: edit code, build, invoke, see output. SAM doesn't have built-in file watching, but you can use tools like `nodemon` or `entr`. I use a simple script:

A simple file watcher script to auto-invoke after changes.
#!/bin/bash
# Watch for changes and rebuild/invoke
while true; do
  inotifywait -r -e modify src/ template.yaml
  sam build && sam local invoke MyFunction -e event.json
done

For Node.js, you can also use `aws-lambda-ric` directly with `nodemon` for faster startup. But SAM builds are more reliable for catching packaging errors.

Structured Logging and Tracing

When debugging locally, `console.log` is your friend. But in production, you need structured logs. I use the AWS Powertools libraries (Python, TypeScript) to add correlation IDs and structured JSON logging. Locally, I pipe the output through `jq` for readability: `sam local invoke ... 2>&1 | jq '.'`.

Structured logging with AWS Powertools for Python.
from aws_lambda_powertools import Logger

logger = Logger(service="my-function")

def lambda_handler(event, context):
    logger.info("Processing event", extra={"event_id": event.get("id")})
    # ... your code ...
    return {"statusCode": 200}

The cloud is a lie. Your local environment is a simulation. The real serverless environment has IAM, VPCs, cold starts, and rate limits. Test early, test often, but never trust local entirely.

Common Pitfalls and How to Avoid Them

  • arrow_rightIAM permissions: Locally, your function uses your AWS credentials (or no credentials for emulators). In production, it uses a role. Test with the same role by using `sam local invoke --profile myrole` or by setting environment variables that mimic the role's permissions.
  • arrow_rightEnvironment variables: Keep a `.env` file that matches production. Use SAM's `Parameters` section to inject them.
  • arrow_rightEvent payloads: Use real events from CloudWatch Logs. Capture a sample event from production and save it as `event.json`.
  • arrow_rightCold starts: As mentioned, test with `--container-reuse Never` and add artificial delays to mimic initialization.
  • arrow_rightResource limits: Lambda has memory, timeout, and concurrency limits. Set `MemorySize` and `Timeout` in `template.yaml` to match production.
lightbulb

Use `sam local start-api` to emulate API Gateway locally. It supports hot-reload with `--warm-containers LAZY` and lets you test HTTP endpoints with actual HTTP requests.

Wrapping Up

Local debugging of serverless functions is not a perfect replica of production, but it's far better than deploying every time you need to test a change. With SAM, emulators, and a good iteration loop, you can catch most bugs early. The cold start issue I mentioned would have been caught if I had tested with a fresh container and realistic network conditions.

Remember: the goal is to reduce the feedback loop. The faster you can test a change, the more you'll actually do it. Invest in your local setup — it pays off every time you avoid a production incident.

70%

of serverless bugs can be caught locally with proper emulation

Frequently asked questions

Can I debug AWS Lambda functions locally with breakpoints?

Yes, with SAM CLI you can run `sam local invoke` and attach a debugger (e.g., VS Code) using the `--debug-port` flag. You'll need to configure your IDE's debugger to attach to the port. Alternatively, you can use `aws-lambda-ric` directly for Node.js or Python runtimes.

How do I emulate AWS services like DynamoDB or S3 locally?

Use DynamoDB Local (a downloadable JAR) or LocalStack, which emulates many AWS services. For SAM, you can configure environment variables in `template.yaml` to point to local endpoints. Keep in mind that these emulators may not support all features (e.g., DynamoDB TTL or S3 event notifications).

What if my function works locally but fails in production?

This usually indicates a difference between local and cloud environments. Common culprits: missing IAM permissions, different region, environment variable differences, or timing issues like cold starts. Use the same execution role and environment variables as production, and test with realistic payloads. Also check CloudWatch Logs for stack traces.

Is it worth using `docker-lambda` instead of SAM?

`docker-lambda` runs the actual Lambda runtime in a Docker container, so it's more faithful to the production environment than SAM's emulation. I use it for edge cases like native dependencies (e.g., `sharp`, `bcrypt`). However, SAM is easier for integration with API Gateway and step-through debugging. Choose based on your needs.