Lambda is a serverless, event-driven compute service that lets you run code for virtually any type of application or backend service without provisioning or managing servers.
To better understand the optimizations to our lambda functions we can divide them in 3 different scope:
- Cold start optimization: a few tricks to reduce the initialization of our lambda function;
- Code optimization: here we could talk for hours, we will focus on the main best practices you can adopt;
- Resource optimization: we are going to use a tool called AWS Lambda Power Tuning by Alex Casalboni to find the right balance between cost and latency.
In all of them we will also test out AWS Lambda NodeJS 16 vs NodeJS 18, let's see which one is faster ποΈ.
Cold start optimization
Here are 7 tips for you to reduce the init duration of your lambdas and make them faster.
When the Lambda service receives a request to run a function via the Lambda API, the service first prepares an execution environment. During this step, the service downloads the code for the function, which is stored in an internal S3 bucket (or in Amazon ECR if the function uses container packaging). It then creates an environment with the memory, runtime, and configuration specified. Once complete, Lambda runs any initialization code outside of the event handler before finally running the handler code.
1. Do not install aws-sdk (v2) or @aws-sdk (v3) dependencies
The aws-sdk is available out of the box from the AWS Lambda default image, so you don't need to install any more dependencies.
2. Use @aws-sdk instead of aws-sdk
With JSv3 AWS has been able to reduce the bundle size by almost 75% when compared to JSv2.
3. Use esbuild
Use esbuild to minify and bundle your AWS Lambda function, you can achieve small package and so faster load initialization time of your lambda function. If you use AWS CDK you can use the package 'aws-cdk-lib/aws-lambda-nodejs' and bundle you AWS Lambda like this:
bundling: { minify: true, target: 'node18', }
4. Avoid wildcard
When importing modules you should not import all your modules, just the one you need. Here are some examples you should avoid:
const { DynamoDB } = require('aws-sdk'); //8.1MB const dynamodb = new DynamoDB(); const AWS = require('aws-sdk'); //8.1MB const dynamodb = new AWS.DynamoDB();
Instead you should import your modules like this:
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); //340kb const dynamodb = new DynamoDBClient(); const DynamoDB = require('aws-sdk/clients/dynamodb'); //340kb const dynamodb = new DynamoDB();
That's some improvement, from 8MB to 340KB which means 24 times lighter.
5. Which programming language matter
AWS Lambda supports a lot of languages, but some of them are faster them other. In this article, New Relic, bring all of them in comparison and the top 2 performer are NodeJS and python. Although it's a little bit older the *top 2 performer are still NodeJS and python. https://newrelic.com/resources/ebooks/serverless-benchmark-report-aws-lambda-2020.
6. Place your lambda outside the VPC
First thing first, sometimes you have to place your Lambda within the VPC so obviously this tip is not for those dark time. The cold start was drastically reduced in 2019, before that cold starts within VPCs could add up to 10 seconds of latency. As of now it has improved but it still takes away a few hundreds ms of latency.
7. Bonus: warm up you lambda
This one is a little trick you can use if you want your AWS Lambda container to always be ready. You can simply schedule an Eventbridge Rule which invokes your lambda function every 5 minutes, just keep in mind if there are 2 or more parallel execution it will start up another container, hence you will have the cold start for the new parallel execution.
8. Bonus: keep your lambda always warmed
If you really don't want to have cold start you could add some provisioned concurrency which initializes a number of execution so your environment is always ready and you do not need to wait for cold starts. Although I don't like using it because I think it's better having a Fargate container always up and running, but sometimes it could come handy without changing the stack.
β οΈ Note: the tests has been performed on some basic execution such as: initialize DynamoDB client, execute Fibonacci algorithm of n=34 and then wait 100ms for 34 times.
Testing results:
Let's test how our optimizations affect cold starts. I did not install any aws-sdk dependencies if they were already available out of the box, which means:
- nodejs18 use JS_V3 and there aren't external packages installed;
- nodejs16 not-optimized hasn't installed any external packages;
- nodejs16 optimized has installed JS_V3 DynamoDB package.
Here are the major take away:
- nodejs18 optimized is 30% faster than the not optimized one;
- nodejs16 optimized is 37% faster than the not optimized one;
- π€―nodejs16 optimized is 30% faster than nodejs18 optimized.
Code optimization
Let's face it, there will always be a better code, we can always improve our code so I won't focus on some code review type of tips, instead I'll show you some simple but effective way to reduce your AWS Lambda latency.
1. Reuse your TCP connections
By default every NodeJS http agent creates a new TCP connection for every request. To avoid the cost of it we can reuse the existing connection. This one is fairly simple:
- JSv3 (@aws-sdk) has it enabled by default so you don't need to do anything π;
- when running with JSv2 you just need to set as environment variable AWS_NODEJS_CONNECTION_REUSE_ENABLED=true (or 1).
2. Reuse the Lambda container
This one is massive, you should initialize your clients outside your handler. This is true even if you want to have a little cache. What does it mean? It means that every execution, within the same Lambda container, will reuse the object that are initialized outside the handler. Let's see some example:
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); const dynamodb = new DynamoDBClient({ region: 'eu-central-1' }); let cache = null; exports.handler = async () => { if (!cache) { cache = await dynamodb.query(...); } return cache; }
as you can see I'm using a cache object and it is initialized outside the handler which means: every time an AWS Lambda runs within this container it will have access to the cache and returns it without querying DynamoDB.
3. Execute code in parallel
I know you knowπ but for the sake of completeness I must add it. If you can run your code in parallel it will be faster, take this example:
const promises = []; for (let index = 0; index < 10; index += 1) { promises.push(sleep(100)); //sleep returns a promise } await Promise.all(promises);
it will execute in something like 100ms, meanwhile this (pseudo) code right below will take 1s in total to finish
for (let index = 0; index < 10; index += 1) { sleep(100); }
For this example it means our code is 10x faster. π
4. Don't log too much
This one is essential for your Amazon CloudWatch billings πΈ. I'm not talking about not logging PII (personally identifiable information), that's up to you; I'm talking about logging only essential information we need to debug when errors happens, you could do something like this for example
exports.handler = async (event) => { console.debug(event); ... const response = await ses.sendEmail(params).promise(); console.info(`Sent email to ${event.request.userAttributes.sub}`); console.debug(response); ... return event; }
we could use a logger which logs debug level only when we are in test environments and then for production environment we got our info level which tell us everything that happened in a few words. If you are interested in a little more sophisticated logging design you could read this post which is really insightful: https://cloudash.dev/blog/saving-aws-lambda-cloudwatch-costs.
Testing results:
Our tests will be great mostly because we run code in parallel, which means we did not wait 34 times sequentially (which is 3400ms) but we waited 34 times 100ms in parallel which is something like 100ms in total.
Here are some take away:
- nodejs18 optimized is 3.72 times faster than the not optimized;
- nodejs16 optimized is 3.56 times faster than the not optimized;
- nodejs18 optimized is just 5.4% faster than nodejs16 optimized.
Lambda resource optimization
Well this chapter amazing, with just a few ticks we can optimize our Lambda function by a lot.
1. Use Graviton image
When possible you should use Arm64 instead of x86_64 because it uses AWS Graviton processors which performs better, up to 34% performance improvement and 20% cost reduction. If you want to read more about it I suggest you read this post https://aws.amazon.com/blogs/aws/aws-lambda-functions-powered-by-aws-graviton2-processor-run-your-functions-on-arm-and-get-up-to-34-better-price-performance/ written by the one and only Danilo Poccia.
2. Use shot timeouts
This one is essential at scale, if timeouts are shorter then you can run more AWS Lambda function. For this I suggest you also keep track of it using a filter patter on Amazon Cloudwatch on the log "Task timed out after".
3. Adjust your memory
This one is my favorite, by deploying a simple CloudFormation stack you can find out which is the right size for your Lambda function memory. We use the AWS Lambda Power Tuning which is really easy and consistent with its findings. Let's see how to use it in 4 simple steps:
- Deploy the infrastructure by pressing the deploy button here https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:451282441545:applications~aws-lambda-power-tuning
- Go to the StepFunction console https://console.aws.amazon.com/states/ and select the newly created state machine
- Press on "New Execution" and insert the json below π
{ "lambdaARN": "${YOUR_LAMBDA_ARN}", "num": 50, //times it will be invoked per memory values "payload": {}, "powerValues": [128, 256, 512, 1024] //memory values you want to test }
- Press on the "Optimizer" step of the execution and scrolling down you should see a link which will show your results
Pretty simple isn't it? Well this is the result we are waiting for, I will compare only nodejs18 for simplicity:
Reading it we can establish:
- 128MB takes 6.2s to complete and costs 0.0000107$ per execution;
- 512MB takes 1.6s to complete and costs 0.0000111$ per execution;
- 1024MB takes 0.87s to complete and costs 0.0000118$ per execution;
- 128MB has the best price, but for a 10.28% costs increase we can reduce latency by 7 times π.
I can trade a 10% costs increase for a x7 better user experience, hence I'm going to upgrade the Lambda memory to 1024MB.
Testing results:
Let's see how our optimization really did improve the overall latency. Given that this one is our last test we will compare our fully optimized lambdas with the ones not optimized at all.
Our optimized lambdas are 17 times faster than not optimized ones π. While nodejs18 and nodejs16 are pretty much similar after cold starts.
Conclusion
In this blogpost we went through all the optimizations we can do for speeding up AWS Lambda functions and therefore spend less. This was really insightful to make because we depth dive into some small adjustments we can do in less than 5 minutes. All of them combined have been able to increase the performance by 17 times π―. As per nodejs16 vs nodejs18, the only thing I was able to find was that nodejs16 initialization duration is 30% faster than the nodejs18 one.
You can find the project here https://github.com/Depaa/lambda-optimizerπ
Thank you so much for reading! π I will keep posting different AWS architecture from time to time so follow me on LinkedIn π https://www.linkedin.com/in/matteo-depascale/.