esbuild, when bundling Node.js Lambda functions in AWS CDK, can sometimes lead to surprisingly large deployment packages, even when you think you’ve pruned dependencies.
Here’s a quick demo of a Lambda function using aws-sdk and axios and how esbuild handles it:
// lambda/index.ts
import { LambdaClient, ListFunctionsCommand } from "@aws-sdk/client-lambda";
import axios from "axios";
export const handler = async (event: any) => {
const lambdaClient = new LambdaClient({});
const command = new ListFunctionsCommand({});
const response = await lambdaClient.send(command);
const apiResponse = await axios.get("https://httpbin.org/get");
return {
statusCode: 200,
body: JSON.stringify({
lambdaFunctions: response.Functions?.length,
apiData: apiResponse.data,
}),
};
};
And here’s the CDK lambda.Function construct:
// lib/my-lambda-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class MyLambdaStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const bundlePath = './lambda'; // Directory containing index.ts
new lambda.Function(this, 'MyEsbuildLambda', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(bundlePath, {
// esbuild options
bundling: {
external: ['@aws-sdk/*'], // Example of externalizing
minify: true,
sourcemap: false,
target: 'es2020',
platform: 'node',
format: 'cjs', // CommonJS for Node.js Lambda
bundle: true, // Crucial: tells CDK to use a bundler
logLevel: lambda.LogLevel.INFO,
// esbuild options for the bundler itself
metafile: true, // Useful for debugging size
// If you need to specify the esbuild binary path:
// esbuildPath: '/path/to/your/esbuild',
},
}),
});
}
}
When you run cdk deploy, CDK invokes esbuild under the hood. It takes your Lambda handler code and its dependencies, analyzes them, and creates a single JavaScript file (or a few, depending on configuration) that can be deployed to Lambda. The bundle: true option is what activates this process.
The core problem esbuild solves is dependency management for Lambda. Instead of uploading your entire node_modules directory (which can be huge and lead to cold start issues), esbuild intelligently packages only the code that your Lambda function actually uses. It resolves imports, inlines code from dependencies, and can even minify it to reduce size.
The external option is a key lever. By default, esbuild tries to bundle everything. If you have AWS SDK v3 packages, for example, they are already available in the Lambda runtime environment. Bundling them again is redundant and bloats your deployment package. Marking them as external: ['@aws-sdk/*'] tells esbuild not to include them in the bundle, assuming they’ll be present at runtime.
The target and platform options ensure compatibility. platform: 'node' is essential for Node.js runtimes, and target: 'es2020' (or similar) specifies the ECMAScript version to transpile down to, balancing modern JavaScript features with Lambda’s runtime capabilities. format: 'cjs' is typically used for Node.js Lambda handlers.
To see what esbuild is actually doing, enable metafile: true. This generates a metafile.json file in your .aws-cdk/esbuild directory (or similar temporary build location) after the bundle is created. This file is a detailed JSON report showing every file esbuild considered, what it included, and what it ignored. Examining this is crucial for understanding why your bundle size is what it is.
The most surprising thing about esbuild’s bundling is how it handles tree-shaking. It doesn’t just copy code; it performs static analysis to determine which exports from a module are actually imported and used by your function. If a dependency has thousands of lines of code but your function only uses a few functions from it, esbuild will attempt to include only those used parts. This is a significant optimization over simply packaging the entire node_modules.
The logLevel: lambda.LogLevel.INFO option in the CDK construct is invaluable. It makes esbuild print detailed information during the bundling process, including which files are being processed and what decisions are being made. This can often reveal unexpected inclusions or omissions.
When you specify external: ['@aws-sdk/*'], you’re relying on the AWS SDK being pre-installed in the Lambda execution environment. This is generally true for most AWS SDK versions compatible with the Node.js runtime you select. If you were targeting a very old runtime or using a custom runtime, you might need to bundle these yourself.
The format: 'cjs' option is important because Node.js Lambda handlers typically expect CommonJS modules. While you might write your source code using ES Modules (ESM), esbuild will transpile it to CJS for compatibility with the Node.js runtime. If you were building for a browser environment, you’d use format: 'esm' or format: 'iife'.
The sourcemap: false setting is a trade-off between debuggability and bundle size. While source maps are helpful for debugging, they add to the package size. For production Lambdas, it’s often recommended to disable them and rely on cloud logging and potentially separate debugging workflows.
The next challenge you’ll likely encounter is managing environment-specific configurations for your Lambda function, especially when dealing with multiple environments (dev, staging, prod) managed by CDK.