After deploying hundreds of serverless applications to production, we've learned what works and what doesn't. These battle-tested best practices will help you build scalable, maintainable, and cost-effective serverless applications on AWS.
1. Design for Statelessness
Lambda functions are ephemeral by design. Never store state in function memory or the filesystem beyond the execution context.
// ❌ Bad: Storing state in memory
let userCache = {};
exports.handler = async (event) => {
userCache[event.userId] = event.data;
// This cache will be lost when the container is recycled
};
// ✅ Good: Using external storage
const DynamoDB = require('aws-sdk/clients/dynamodb');
const client = new DynamoDB.DocumentClient();
exports.handler = async (event) => {
await client.put({
TableName: 'UserCache',
Item: { userId: event.userId, data: event.data }
}).promise();
};
2. Optimize Cold Starts
Cold starts can impact user experience. Minimize them by keeping functions small, using provisioned concurrency for critical paths, and optimizing dependencies. Learn about the cost implications in our Lambda cost calculator.
- Keep deployment packages under 50MB
- Use tree-shaking to remove unused code
- Initialize SDK clients outside the handler
- Consider using Lambda Layers for shared dependencies
Pro Tip: For Node.js functions, use webpack or esbuild to bundle and minimize your code. This can reduce cold start times by up to 70%.
3. Implement Proper Error Handling
Robust error handling is crucial for production reliability. Implement retry logic, use dead letter queues, and monitor failures.
exports.handler = async (event) => {
try {
const result = await processData(event);
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
console.error('Processing failed:', error);
// Send to DLQ for analysis
await sendToDeadLetterQueue(event, error);
// Return appropriate error response
if (error.retryable) {
throw error; // Let Lambda retry
}
return {
statusCode: error.statusCode || 500,
body: JSON.stringify({
error: 'Processing failed',
requestId: context.requestId
})
};
}
};
4. Use Environment Variables Wisely
Environment variables are great for configuration, but don't store secrets directly. Use AWS Systems Manager Parameter Store or Secrets Manager.
Warning: Environment variables are visible in the Lambda console and CloudFormation templates. Never store sensitive data like API keys or passwords directly in environment variables.
5. Implement Structured Logging
Use structured JSON logging for better observability. This makes it easier to query logs in CloudWatch Insights.
const log = (level, message, meta = {}) => {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
requestId: context.requestId,
...meta
}));
};
// Usage
log('INFO', 'Processing order', {
orderId: '12345',
amount: 99.99
});
6. Set Appropriate Timeouts and Memory
Right-size your functions based on actual usage. Monitor with CloudWatch and X-Ray to find the optimal configuration. See our EC2 vs Serverless cost analysis for performance comparisons.
- Start with 3-second timeout and 512MB memory
- Use AWS Lambda Power Tuning to find optimal settings
- Remember: More memory = More CPU power
- Set timeouts slightly above your p99 execution time
7. Use Async/Await Properly
Leverage async/await for cleaner code and better error handling. Avoid callback hell and properly handle promise rejections.
// ✅ Good: Parallel execution when possible
exports.handler = async (event) => {
const [userData, orderData, inventoryData] = await Promise.all([
fetchUserData(event.userId),
fetchOrderData(event.orderId),
checkInventory(event.items)
]);
return processOrder(userData, orderData, inventoryData);
};
8. Implement Idempotency
Make your functions idempotent to handle retries safely. Use idempotency keys and check for duplicate processing.
Functions should produce the same result regardless of how many times they're called with the same input. This is critical for event-driven architectures.
9. Monitor and Alert Proactively
Set up comprehensive monitoring with CloudWatch Alarms for key metrics:
- Error rate > 1%
- Throttling occurrences
- Duration approaching timeout
- Concurrent executions near limit
- Dead letter queue messages
10. Use Infrastructure as Code
Always define your serverless infrastructure using IaC tools like SAM, CDK, or Terraform. This ensures consistency and enables CI/CD.
# SAM Template Example
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 30
MemorySize: 512
Runtime: nodejs18.x
Tracing: Active
Environment:
Variables:
STAGE: !Ref Stage
Resources:
ProcessOrderFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./src
Handler: orders.handler
ReservedConcurrentExecutions: 10
DeadLetterQueue:
Type: SQS
TargetArn: !GetAtt DeadLetterQueue.Arn
Bonus: Cost Optimization Tips
Want to see real cost savings in action? Read our case study on cutting AWS costs by 85%.
- Use Step Functions for orchestration instead of chaining Lambdas
- Implement caching with ElastiCache or DynamoDB to reduce compute
- Use SQS for buffering to smooth out traffic spikes
- Enable compression for API Gateway responses
- Set up cost alerts to catch unexpected usage early