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

June 26th

The Cost of Choice

Most companies spend up to 40% to much on cloud, are you? Cut spend, not options. Smart standardizations win.

Cloud cost overruns and growing technical debt rarely stem from tooling alone—they are symptoms of architectural and operational choices. This session looks at how senior technical leaders can regain control by connecting cloud spend directly to business value. We’ll explore unit‑economics thinking, ownership models, and lifecycle management practices that reduce waste while preserving delivery speed. You’ll learn how to combine FinOps principles with technical‑debt controls to create a cloud environment that is financially sustainable and technically healthy.

May 28th

AI AGENTS DESERVE AI PLATFORM

Portable patterns for Azure, AWS and GCP that survive the next upgrade

AI agents are moving rapidly from experimentation into real production use cases, but architectures vary widely across cloud platforms. In this webinar, we compare practical patterns for building and running AI agents on Azure, AWS, and Google Cloud Platform. We’ll focus on what to standardize, where to embrace cloud‑native capabilities, and how to design for security, observability, and future change. The goal is not to pick a winner, but to help leaders understand how to scale agent‑based solutions without locking themselves into fragile designs.

April 23rd

Winning on Repeat: Product Engineering in the Age of AI

Cadence, quality and outcomes over output

Delivering a successful solution once is no longer enough. In the age of AI, organizations need product engineering models that enable them to win consistently across teams, releases, and markets. This session explores how leading organizations evolve from project‑centric delivery to product‑centric execution, supported by AI‑augmented engineering practices. We’ll look at cadence, quality, and accountability, and how leadership decisions shape sustainable delivery performance over time.

April 2nd

GOVERNING AI IN PRODUCTION

Designing cloud and data platforms that survive real-world pressure

Many organizations succeed in building AI proofs of concept, far fewer succeed in scaling them safely into production. This webinar focuses on what it takes to move from experimentation to reliable, governed AI platforms. We’ll discuss platform architecture choices, model governance, security, and policy patterns that enable teams to deploy AI at scale without slowing down delivery. Designed for senior technical leaders, this session provides practical guidance on turning AI initiatives into durable capabilities that deliver value beyond the first demo

March 5th

Navigating Digital Sovereignty and Strategic Cloud Choices

How Organizations Can Balance Innovation, Compliance, and Control in a Multi-Cloud World

In today’s rapidly evolving digital landscape, organisations face increasing pressure to ensure business continuity, maintain public trust, and comply with complex regulations like NIS2, DORA, and GDPR. This webinar explores the critical concepts of digital and operational sovereignty, the strategic importance of hybrid and sovereign cloud models, and the risks of vendor lock-in.