Lessons Learned from Running Serverless Applications in Production on AWS

Ujjwal MaharUjjwal Mahar
Apr 15, 2025·
14 min read

Introduction

Have you heard about serverless on AWS? It sounds amazing: your code scales up automatically, you only pay when it runs, and you don't have to worry about servers. Tools like AWS Lambda, API Gateway, and DynamoDB make this possible.

Getting started is pretty easy. You can set up a basic function with a few clicks or a simple template. But using these tools for a real application, one that customers use every day? That's when things get interesting, and you start learning lessons you didn't expect.

Our team jumped into serverless for some important projects because the benefits looked great. And they often are! But it wasn't always easy. We ran into some surprises. Here are the biggest things we learned the hard way while running serverless apps in production.

Lesson 1: You Need to See What Your Code is Doing

What We Thought: "We have logs, just like our old apps. That's probably fine."

What Really Happened: We were wrong! When serverless code runs across many small pieces (Lambda, API Gateway, databases), figuring out problems is much harder than with one big application. If something broke, finding the cause felt like searching for a needle in a haystack – actually, multiple haystacks! It took forever to track down simple errors.

How We Fixed It:

  • Better Logs: We made sure all our logs were in a consistent format (JSON) with important info like a unique ID to track each request as it moved between services. This made searching logs much easier.

  • Using AWS X-Ray: Think of X-Ray like a map for your requests. It shows you exactly where a request went, how long each step took, and where things slowed down or failed. It was a huge help for finding problems quickly.

  • Better Alerts: Basic alerts tell you if a function crashed. But we started adding alerts for business problems (like "failed orders") and watching for high error rates. This way, we often knew about issues before users started complaining.

Lesson 2: Keep an Eye on Your Spending

What We Thought: "Pay-per-use sounds cheap! We only pay when it runs."

What Really Happened: Serverless can be cheap, but costs can sneak up on you if you're not careful. We found out the hard way. Maybe a function ran too long or used too much memory. Maybe a bug caused a function to call itself over and over. Maybe we logged way too much information. One small mistake even caused millions of function runs over a weekend, costing us real money!

How We Fixed It:

  • Check the Bill Often: We started using AWS tools (like Cost Explorer) regularly to see exactly where the money was going. Adding "tags" (labels) to everything helped us sort costs by project or service.

  • Set Spending Limits (Budgets): We set up AWS Budgets to send us alerts if costs started getting too high. This gave us a warning before the end of the month.

  • Give Functions the Right Resources: We stopped guessing how much memory our Lambda functions needed. We looked at the actual usage and adjusted them – not too much (wasteful), not too little (slow or failing).

  • Think About Costs When Designing: We started thinking about how different choices affect the price. For example, sometimes grouping messages together is cheaper than sending lots of small ones.

Lesson 3: Sometimes Serverless is Slow at First (Cold Starts)

What We Thought: "Lambda scales up instantly!"

What Really Happened: Mostly, yes. But when a function hasn't run for a little while, the very first request takes extra time to start up. This is called a "cold start." It might only be a second or two, but if a user is waiting for an answer from your API, that delay feels really bad.

How We Fixed It:

  • Keep Some Functions Ready: For important functions where speed matters most, we use something called "Provisioned Concurrency." It costs a bit extra, but it keeps some function instances warm and ready to go, eliminating cold starts.

  • Keep Code Small and Fast: Smaller code packages and fewer extra bits usually mean faster start times. We also learned to set up things like database connections outside the main part of the function, so they can be reused if the function stays warm.

  • Maybe Choose a Different Language: Some programming languages tend to start faster than others. We looked at which ones worked best for our needs.

  • Don't Make Users Wait: If the work doesn't need to be done instantly, we often have the API just put a message in a queue (like SQS) and tell the user "Got it!". Then, another Lambda function processes the message in the background. The user doesn't notice any cold start delay.

Lesson 4: Handle Errors Carefully (Especially Retries)

What We Thought: "If a function fails, things like SQS will just retry it automatically. Easy!"

What Really Happened: Retries are great, but they can be dangerous! Imagine a function that charges a credit card. If it fails after charging but before saving the order, a simple retry might charge the customer again. We learned functions need to be "safe to retry" (the technical term is idempotent). Also, sometimes an error happens that retrying will never fix (like bad data). Retrying those just wastes time and money.

How We Fixed It:

  • Make Functions Safe to Retry: Before doing something important (like charging money or changing data), our functions now check if that exact task was already completed successfully, often using a unique ID.

  • Use a "Dead-Letter Queue" (DLQ): We set up a special queue (a DLQ) for messages that fail too many times. Instead of blocking everything, the failed message goes to the DLQ where we can look at it later and figure out what went wrong. We watch these DLQs closely!

  • Catch Errors Smartly: Inside our functions, we started catching errors and thinking: Is this a temporary glitch (okay to retry)? Or is this a permanent problem (stop retrying, log the error, maybe send the message straight to the DLQ)?

Lesson 5: Use Code to Set Up Your AWS Stuff (IaC)

What We Thought: "It's just a few functions, we can set them up by clicking in the AWS console."

What Really Happened: A real serverless app isn't "just a few functions." It's usually lots of functions, API parts, database tables, queues, permissions, etc. Trying to manage all that by clicking around in different environments (like development, testing, production) is a recipe for mistakes and confusion. It's slow, hard to track changes, and easy to make environments different without meaning to.

How We Fixed It:

  • Define Everything in Code: We now use tools like AWS SAM, CDK, or Terraform to write code that describes all the AWS pieces our application needs. This code can be saved, shared, and versioned like any other code.

  • Automate Updates: We set up systems (called CI/CD pipelines) that automatically test and apply changes from our infrastructure code. This is much safer and faster than manual clicking.

  • Get Identical Environments: Using code makes it easy to create exact copies of our setup for testing or different stages. This helps avoid the "it worked on my machine!" problem.

Wrapping Up

Serverless on AWS has been great for our team. It lets us move faster and build things that can handle huge amounts of traffic. But it's not magic. You need to learn how these distributed systems really work.

Thinking about how to see what's happening, watching costs, dealing with cold starts, handling errors safely, and using code to manage your setup – these aren't optional extras. They are the key lessons we learned from using serverless for real. It takes some effort to learn this stuff, but once you do, you can really unlock the benefits of serverless on AWS.

Our team dove headfirst into serverless for several key projects, lured by its compelling advantages. While we've absolutely reaped the benefits, the path wasn't always smooth. We encountered challenges we didn't anticipate based on the initial hype. Here are some of the most critical, hard-won lessons we learned operating serverless applications in the demanding environment of production on AWS.

Lesson 1: Observability Isn't a Feature, It's the Foundation

The Naive Start: We initially figured, "We have CloudWatch Logs, just like our old EC2 apps. That should be enough."

Production Pain: Wrong. Debugging a distributed serverless system is vastly different from troubleshooting a monolith. A single user click might ripple through API Gateway, multiple Lambda functions, DynamoDB reads/writes, and maybe even SQS queues. When things broke, piecing together that fragmented journey across dozens of separate log streams was excruciatingly slow. Finding the root cause of even simple errors felt like searching for needles in multiple haystacks simultaneously.

The Essential Fixes:

  • Structured Logging is Mandatory: We enforced strict JSON logging across all Lambda functions. Every log entry had to include key context like the correlationId (tracing the request across services), userId (if available), and specific function identifiers. This instantly made CloudWatch Logs Insights usable for actual debugging, not just viewing raw output.

  • Embrace AWS X-Ray: Distributed tracing isn't optional for serverless. Implementing X-Ray was a game-changer. Suddenly, we could visualize the entire request path, see exact timings for each hop (API Gateway -> Lambda -> DynamoDB), identify bottlenecks immediately, and pinpoint which downstream call failed. Enabling the basic tracing checkbox in Lambda is step one; instrumenting your code with the X-Ray SDK to create custom subsegments for specific business logic or SDK calls provides invaluable granularity.

  • Go Beyond Basic Metrics: Lambda's built-in metrics (invocations, errors, duration, throttles) are crucial, but not enough. We started emitting custom CloudWatch Metrics directly from our functions for critical business actions (e.g., OrdersProcessed, PaymentFailures, ItemsScanned). We then built targeted CloudWatch Alarms on these custom metrics, plus critical technical metrics like high error rates (>1%), DLQ depths (>0), and persistent throttles. This shifted us from reactive debugging to proactive alerting.

Lesson 2: Serverless Costs Aren't Magic – Monitor or Be Surprised

The Misconception: "Pay-per-use means it's always cheap! If it doesn't run, we don't pay."

Production Reality: While often cheaper overall, serverless costs can explode unexpectedly if you're not careful. We learned this the hard way. Causes included: inefficient Lambda code (high memory and long duration), accidental recursive function calls leading to infinite loops, poorly tuned SQS retry policies causing functions to hammer a failing downstream service, and massive CloudWatch Logs ingestion from overly verbose logging. One misconfigured event trigger cost us hundreds of dollars over a weekend due to millions of unnecessary invocations.

The Cost Control Toolkit:

  • Cost Explorer is Your Best Friend: Regularly dive into AWS Cost Explorer. Tag everything (functions, tables, queues, APIs) religiously with project codes or service names. Filter by tags and services to understand exactly where your money is going. Identify the top cost drivers – is it Lambda compute, data transfer, DynamoDB reads, or CloudWatch Logs?

  • Set AWS Budgets with Alerts: Don't wait for the monthly bill. Set up AWS Budgets for specific services, tags, or your overall account, with alert thresholds (e.g., at 50%, 80%, 100% of expected spend). Get notified before costs spiral out of control.

  • Right-Size Your Functions (Continuously): Don't guess memory allocation. Analyze function performance using CloudWatch metrics or AWS Compute Optimizer. Over-provisioning wastes money directly; under-provisioning increases duration (costing more compute time) and can lead to timeouts. Finding the "sweet spot" often requires experimentation.

  • Architect with Cost in Mind: Understand the pricing models. Is API Gateway's per-request cost significant at your scale? Would batching SQS messages reduce Lambda invocations? Are you performing costly DynamoDB scans instead of efficient Query or GetItem operations? Is extensive DEBUG logging necessary in production, or can it be controlled via environment variables?

Lesson 3: Cold Starts Happen – Mitigate or Accept the Latency

The Oversimplification: "Lambda scales instantly!"

Production Reality: Yes, it scales out, but the first request to a new or inactive function instance incurs a "cold start." This is the time AWS needs to provision the environment, download your code package, and initialize the runtime. For user-facing synchronous APIs via API Gateway, adding hundreds of milliseconds, or sometimes even seconds, of unpredictable latency during a cold start can lead to a terrible user experience.

Strategies for Mitigation:

  • Provisioned Concurrency (The Big Gun): For your most latency-sensitive functions, Provisioned Concurrency is the most effective solution. It keeps a specified number of execution environments warm and ready, eliminating cold starts for requests hitting those instances. Be aware: this incurs an additional cost, as you pay for the provisioned capacity whether it's used or not. Use it strategically.

  • Optimize Your Code & Package: Keep your deployment artifacts small. Minimize dependencies; use tools like Webpack (for Node.js) or dependency pruning. Initialize expensive resources (like database connections or SDK clients) outside the main function handler. This allows them to be reused across multiple invocations within the same warm execution environment, reducing initialization time on subsequent requests.

  • Consider Runtime Choice: We observed differences. Node.js and Python generally have faster cold starts than Java (unless using optimizations like GraalVM). Compiled languages like Go can also be very fast. Benchmark and choose based on your application's needs and your team's expertise.

  • Design Asynchronously: Where possible, avoid making the user wait synchronously for long-running processes. An API Gateway endpoint could simply drop a message onto SQS and return a 202 Accepted immediately. A separate Lambda function processes the message from the queue asynchronously. This masks the latency (including potential cold starts) of the background processing from the end-user.

Lesson 4: Idempotency and Error Handling Are Non-Negotiable

The Initial Hope: "Event sources like SQS have built-in retries, so we're covered if a function fails."

Production Reality: Retries are essential but dangerous if not handled carefully. If your function performs an action with side effects (charging a credit card, updating inventory, sending an email) and isn't idempotent (meaning running it multiple times with the same input produces the same result), retries can cause chaos. Imagine double-charging a customer because the function failed after payment but before confirming success. Furthermore, not all errors are retryable. A temporary network glitch? Retry. Invalid input data or a critical bug in the code? Retrying just wastes resources and delays fixing the real problem, potentially blocking your queue with "poison pill" messages.

Building Resilience:

  • Design for Idempotency: This is critical for any function with side effects triggered by asynchronous events. Common techniques include:

    • Using unique transaction IDs passed in the event payload. Before processing, check a DynamoDB table (using conditional writes) to see if that ID has already been successfully processed.

    • Designing database updates to be inherently safe for re-execution (e.g., SET status = 'processed' WHERE status = 'pending').

  • Utilize Dead-Letter Queues (DLQs): Configure DLQs (usually an SQS queue or SNS topic) for your asynchronous Lambda event sources (SQS, SNS, EventBridge, etc.). When a message fails processing after the configured number of retries, AWS automatically sends it to the DLQ. This prevents failed messages from blocking the main queue and allows you to inspect, potentially fix, and manually re-drive these failed events later. Monitor your DLQ depths! A growing DLQ is a critical alert.

  • Smart Error Handling within the Function: Use try...catch blocks effectively. Differentiate between transient errors (e.g., downstream service throttling, temporary network issues) that might resolve on retry (so re-throw the error or allow Lambda's retry mechanism to handle it) and permanent errors (e.g., validation failed, unrecoverable state). For permanent errors, catch them, log detailed context, potentially emit a custom metric, and return successfully (or specifically signal not to retry) to prevent pointless retries and ensure the message goes to the DLQ if configured.

Lesson 5: Infrastructure as Code (IaC) Prevents Production Chaos

The Temptation: "It's just a few functions; we'll manage them through the AWS Console for now."

Production Reality: A typical serverless application isn't just "a few functions." It's a constellation of Lambda functions, API Gateway resources (routes, methods, authorizers), fine-grained IAM roles and policies, DynamoDB tables, SQS queues, SNS topics, EventBridge rules, etc. Trying to manage this complex web manually across multiple environments (dev, staging, prod) via clicks is a recipe for disaster. It's slow, error-prone, impossible to version control, and makes replicating environments reliably a nightmare. We quickly realized manual management was unsustainable.

The IaC Imperative:

  • Pick Your Framework and Stick To It: Mandate the use of an IaC tool from day one for anything beyond trivial experimentation. AWS SAM (Serverless Application Model) and AWS CDK (Cloud Development Kit) are excellent, AWS-native options specifically tailored for serverless. Terraform and the Serverless Framework are also powerful, popular choices.

  • Automate Everything with CI/CD: Integrate your IaC framework into a robust CI/CD pipeline (e.g., AWS CodePipeline, GitHub Actions, GitLab CI). Every commit should trigger automated builds, tests (unit, integration), and deployments. This ensures consistency, repeatability, and reduces the risk of manual deployment errors.

  • Achieve True Environment Parity: IaC makes spinning up identical copies of your entire stack for different environments straightforward. This drastically reduces the "but it worked in dev!" class of problems.

Conclusion

Adopting serverless on AWS for production applications has been transformative for our team, enabling faster development cycles and incredible scalability. However, it's not a magic bullet. Success requires moving beyond the initial hype and embracing practices tailored to distributed, event-driven architectures. Prioritizing observability, diligently managing costs, mitigating cold starts strategically, engineering for idempotency and robust error handling, and enforcing disciplined automation through Infrastructure as Code are not optional extras – they are the essential lessons learned from running serverless in the real world. The learning curve is undeniable, but mastering these principles unlocks the true power and potential of the serverless paradigm on AWS.

11
Subscribe to my newsletter

Read articles from Ujjwal Mahar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ujjwal Mahar
Ujjwal Mahar