Node.js Lambda Package Optimization: Decrease Size and Increase Performance Using ES Modules

This article explains how to optimize Node.js AWS Lambda functions packaged in ES module format. It also shows an example with bundling and AWS CDK, as well as results for gained performance improvement.

Defining the code package format

Node.js has two formats for organizing and packaging the code: CommonJS (CJS) — legacy, slower, larger, and ES modules (ESM) — modern, faster, smaller. CJS is still a default module system, and sometimes the only supported option for some tools.

 

Let’s say you have a Node.js project, but you haven’t bothered with this before. You may now ask yourself — in which format is my code packaged?

 

Let’s look at some JavaScript code examples:

				
					// CommonJS

// module.js
module.exports = function example() {
    return 'Hello!'
}

// index.js
const example = require("./module")
example() // Hello!

				
			
				
					// ES modules

// module.js
export default function example() {
    return 'Hello!'
}

// index.js
import example from "./module"

example() // Hello!

				
			

In JavaScript, it is clear by just looking into the code. But in TypeScript, you may find yourself writing code in ESM syntax, but using CJS in runtime! This happens if TypeScript compiler is configured to produce CommonJS output format. Compiler settings can be adjusted in tsconfig.json file and we will show how to avoid CJS output with an example later.

 

There are two ways for Node.js to determine package format. The first way is to look up for the nearest package.json file and its type property. We can set it to module if we want to treat all .js files in the project as ES modules. We can also omit type property or put it to commonjs, if we want to have code packaged in CommonJS format.

 

The second way to configure this is using file extensions. Files ending with .mjs (for ES modules) or .cjs (for CommonJS) will override package.json type and force the specified format. Files ending with just .js will inherit chosen package format.

ES modules

So how exactly can ESM help us improve Lambda performance?

 

ES modules support features like static code analysis and tree shaking, which means it’s possible to optimize code before the runtime. This can help to eliminate dead code and remove not needed dependencies, which reduces the package size.

 

You can benefit from this in terms of cold start latency. Function size impacts the time needed to load the Lambda, so we want to reduce it as much as possible. Lambda functions support ES modules from Node.js 14.x runtime.

Example

Let’s take one simple TypeScript project as an example, to show what we need to configure to declare a project as an ES module.

 

We will add just couple of dependencies including aws-sdk for DynamoDB, Logger from Lambda Powertools and Lambda type definitions.

				
					// package.json
{
  "name": "lambda-example",
  "version": "1.0.0",
  "type": "module",
  "description": "AWS Lambda function example",
  "dependencies": {
    "@aws-lambda-powertools/logger": "^1.18.0",
    "@aws-sdk/client-dynamodb": "^3.501.0",
    "@aws-sdk/lib-dynamodb": "^3.501.0",
    "@types/aws-lambda": "^8.10.132"
  },
  ...
  
				
			

The type field in package.json defines the package format for Node.js. We are using module value to target ES modules.

				
					// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "lib": [
      "ES2022"
    ],
    "moduleResolution": "Bundler",
    ...
    
				
			

The module property in tsconfig.json sets the output format for TypeScript compiler. In this case, ES2022 value says that we are compiling our code to one of the versions of ES modules for JavaScript.

 

You can find additional info for compiler settings on https://www.typescriptlang.org/tsconfig.

Bundling

To simplify deploy and runtime process, you can use a tool called bundler to combine your application code and dependencies into a single JavaScript file. This procedure is used in frontend applications and browsers, but it’s handy for Lambda as well.

 

Bundlers are also able to use previously mentioned ES modules features, which is the reason why they are important part of this optimization. Some of the popular ones are: esbuildwebpackrollup, etc.

AWS CDK

If you’re using CDK to create your cloud infrastructure, good news is that built-in NodejsFunction construct uses esbuild under the hood. It also allows you to configure bundler properties, so you can parametrize the process for your needs.

				
					const esmFunction = new NodejsFunction(this, 'TestFunctionESM', {
    runtime: Runtime.NODEJS_20_X,
    entry: 'app/functions/lambda-get-item.ts',
    handler: 'handler',
    memorySize: 1024,
    bundling: {
        minify: true,
        sourceMap: true,
        externalModules: [],
        platform: 'node',

        // ESM important properties:
        mainFields: ['module', 'main'],
        format: OutputFormat.ESM,
        banner: 'const require = (await import(\'node:module\')).createRequire(import.meta.url);', 
    },
    ...
    
				
			

With these settings, bundler will prioritize ES module version of dependencies over CommonJS. But not all 3rd party libraries have a support for ES modules, so in those cases we must use their CommonJS version.

NOTE: What’s important to mention is that if you have an existing CommonJS project, you can keep it as is and still make use of this improvement. The only thing you need to add is mainFields property in CDK bundling section, which will set the format order when resolving a package. This might help you if you have some troubles switching the project completely over to ES modules.

 

Let’s use a simple function that connects to DynamoDB as an example. Its job is just to read a record from a database.

				
					import {Logger} from "@aws-lambda-powertools/logger";
import {DynamoDBClient} from "@aws-sdk/client-dynamodb";
import {DynamoDBDocumentClient, GetCommand} from "@aws-sdk/lib-dynamodb";
import {APIGatewayProxyHandlerV2} from "aws-lambda";

const client = new DynamoDBClient({})
const docClient = DynamoDBDocumentClient.from(client)
const tableName = process.env.TABLE_NAME

const logger = new Logger({serviceName: 'lambda-example'});

export const handler: APIGatewayProxyHandlerV2 = async (event) => {

    const id = event.queryStringParameters?.id;
    logger.info(`Getting item with id: ${id}`)

    const command = new GetCommand({
        TableName: tableName,
        Key: {
            id: id
        }
    });

    const response = await docClient.send(command);
    logger.info('Response: ', response)

    return {
        statusCode: 200,
        body: JSON.stringify(response.Item),
    };
};

				
			

We will create two Lambda functions with this same code. One using the CDK example above, and the other one using the same CDK but without ESM bundling properties. It is just to have separate functions in CommonJS and ES modules so it’s easier to compare them.

 

Here is a bundling output during CDK deploy with esbuild:

You can see that ESM version of the function has package size reduced by almost 50%! Source maps file (.map) is also smaller now.

 

esbuild provides a page for visualizing the contents of your bundle through several charts, which helps you understand what your package consists of. It is available here: https://esbuild.github.io/analyze.

 

Here is how it looks like for our test functions:

In this case, CommonJS package is improved by bundler only by minifying the code, which got it down to 500kb. Packages under @aws-sdk take up more than half of the package.

But with using ES module — first approach when bundling, the package size goes down even further. As you can see, there is still some code in CJS format as some dependencies are only available as CommonJS.

Performance results

Let’s see now how much improvement is made by comparing cold start latency between ES module and CommonJS version of the function. Small load test with up to 10 concurrent users was executed to obtain the metrics. Below are visualized results using CloudWatch Logs Insights.

CommonJS

				
					+--------------------+--------------------+--------------------+------------------------+
| avg(@initDuration) | min(@initDuration) | max(@initDuration) | pct(@initDuration, 95) |
+--------------------+--------------------+--------------------+------------------------+
|             314.01 |             260.73 |             353.56 |                 344.65 |
+--------------------+--------------------+--------------------+------------------------+
				
			

ES modules

				
					+--------------------+--------------------+--------------------+------------------------+
| avg(@initDuration) | min(@initDuration) | max(@initDuration) | pct(@initDuration, 95) |
+--------------------+--------------------+--------------------+------------------------+
|           260.1397 |             191.82 |             311.38 |                 303.35 |
+--------------------+--------------------+--------------------+------------------------+
				
			

Numbers above are in milliseconds, so in average we reduced cold start duration by 50+ms, or 17%. Bigger difference is for minimum latency, which was shorter for almost 70ms, or 26%.

 

These are not drastic differences, but from my experience with real-world projects — package size can go down like 10x, and cold start latency by even 300–400ms.

Conclusion

The improvement from using ES modules can be seen even in the simple example above. How much you can lower cold start latency depends on how big your function is and if it needs a lot of dependencies to do its job. But that’s the way it should be, right?

 

For example, for simple functions that just send a message to SQS/SNS and similar, we don’t need dependencies from the rest of the app — like database or Redis client, which might be heavy. And sometimes shared code ends up all over the place.

 

Even if the improvement in your case is not that big, it still might be worth considering using ESM. Just be aware, some tools and frameworks still have bad or no support for ESM.

 

In the end, why would you want to pack and deploy the code you won’t use, anyway? 😄

Published:
22 February 2024

Related white papers