Table of Contents
Open Table of Contents
π Optimize TypeScript Bundles for AWS Lambda with ESBuild
π‘ Benefits of Bundle Optimization
Optimizing TypeScript bundles for AWS Lambda deployment provides measurable benefits across performance, cost, and development efficiency. Plus, your AWS bill will thank you (and so will your manager when they see those reduced costs).
β‘ Performance Impact: Reducing bundle sizes decreases the latency associated with Lambda cold starts. Smaller bundles mean less code to load and parse during function initialization, which translates to faster response times for your users.
π° Cost Reduction: Lower cold start times directly reduce AWS costs, as you pay for the compute time required during function startup. Additionally, smaller deployment packages reduce storage costs and bandwidth usage.
π§ Development Efficiency: Optimized bundles streamline your deployment pipeline by reducing the time and computational resources required during CI/CD processes. This leads to faster deployments and lower infrastructure costs for your build systems.
These optimizations compound over time, improving both user experience and operational costs as your Lambda functions scale.
βοΈ Optimized ESBuild Configuration
Hereβs a comprehensive ESBuild configuration that includes all the optimizations covered in this guide:
// esbuild.config.js
import { build } from 'esbuild';
await build({
entryPoints: ['src/handler.ts'],
bundle: true,
minify: true, // Remove whitespace, shorten variables
treeShaking: true, // Eliminate dead code (default when bundling)
sourcemap: true, // Enable for app code debugging (disable for shared libraries)
mainFields: ["module", "main"], // Prefer ES modules for better tree shaking
format: 'esm', // When possible, output ES modules for optimal analysis
metafile: true, // Generate bundle analysis data
outfile: 'dist/handler.mjs',
platform: 'node',
target: 'node24',
});
π¦ Set minify
to true
Enabling minification removes whitespace, shortens variable names, and eliminates dead code, often reducing bundle sizes by 30-50% or more.
π Before Minification
Hereβs what your TypeScript code might look like before minification (readable, but chunky like a good peanut butter):
// Original bundled output (simplified)
const AWS = require('aws-sdk');
function validateUserInput(userInput) {
if (!userInput) {
throw new Error('User input is required');
}
if (typeof userInput.email !== 'string') {
throw new Error('Email must be a string');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userInput.email)) {
throw new Error('Invalid email format');
}
return true;
}
exports.handler = async (event, context) => {
try {
const parsedBody = JSON.parse(event.body);
validateUserInput(parsedBody);
// Process the request
const result = await processUserData(parsedBody);
return {
statusCode: 200,
body: JSON.stringify({
success: true,
data: result
})
};
} catch (error) {
console.error('Handler error:', error.message);
return {
statusCode: 400,
body: JSON.stringify({
success: false,
error: error.message
})
};
}
};
β‘ After Minification
The same code after ESBuild minification (now it looks like your cat walked across the keyboard, but in a good way):
// Minified output
const e=require("aws-sdk");function r(e){if(!e)throw new Error("User input is required");if("string"!=typeof e.email)throw new Error("Email must be a string");if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.email))throw new Error("Invalid email format");return!0}exports.handler=async(o,t)=>{try{const t=JSON.parse(o.body);r(t);const s=await processUserData(t);return{statusCode:200,body:JSON.stringify({success:!0,data:s})}}catch(e){return console.error("Handler error:",e.message),{statusCode:400,body:JSON.stringify({success:!1,error:e.message})}}};
π³ Set treeShaking
to true
Tree shaking eliminates dead code by analyzing import statements and only including used functions, classes, and variables. Think of it as Marie Kondo for your codebase - if a function doesnβt spark joy (or get called), itβs out! ESBuild enables this by default when bundling, but you can explicitly configure it as shown in the optimized configuration above.
π Tree Shaking Example
Consider a utility library with 6 functions where your Lambda only uses 1:
// utils/helpers.ts - Multiple utility functions
export function validateEmail(email: string): boolean { /* ... */ }
export function formatDate(date: Date): string { /* ... */ }
export function calculateTax(amount: number): number { /* ... */ }
export function generateId(): string { /* ... */ }
export function slugify(text: string): string { /* ... */ }
export function deepClone<T>(obj: T): T { /* ... */ }
// src/handler.ts - Only imports one function
import { validateEmail } from './utils/helpers';
β Without tree shaking: All 6 functions bundled (~12KB)
β
With tree shaking: Only validateEmail
bundled (~2KB)
Real-World Example: Lodash
Tree shaking is especially powerful with large libraries like Lodash (because who actually uses all 300+ utility functions anyway?):
// Before: Importing the entire Lodash library (kitchen sink included)
import _ from 'lodash';
const result = _.uniq([1, 2, 2, 3, 4, 4]);
// Bundle size: ~500KB (that's a lot of unused functions!)
// After: Tree shaking eliminates unused functions
import { uniq } from 'lodash';
const result = uniq([1, 2, 2, 3, 4, 4]);
// Bundle size: ~15KB (97% reduction! π)
πΊοΈ Set sourcemap
to false
for Shared Libraries
Source maps are valuable for debugging your own code, but they can double or triple bundle size. The key is being selective: disable source maps for external libraries (when was the last time you debugged through lodash internals?) while keeping them for your application code that you actually debug.
For shared libraries and dependencies, disable source maps to reduce bundle size. However, you should still enable source maps for your application code to get readable stack traces when debugging your Lambda functions.
π Set mainFields
to ["module", "main"]
Many npm packages ship with both CommonJS and ES module formats. The mainFields
option tells
ESBuild to prioritize ES modules when available, enabling better tree shaking.
Libraries like zod
include both formats in their package.json:
"main"
: CommonJS version (larger, less optimizable)"module"
: ES module version (smaller, better tree shaking)
Setting mainFields: ["module", "main"]
(shown in the optimized configuration) ensures ESBuild
chooses the ES module version when available, falling back to CommonJS when needed.
π Bundle Size Impact
CommonJS version: ~45KB (includes compatibility wrappers) π
ES module version: ~32KB (28% smaller, better tree shaking) β‘
Additional mainFields Options
For maximum compatibility and optimization, you can specify more fields:
await build({
// ... other options
mainFields: [
"module", // ES modules (best for tree shaking)
"jsnext:main", // Legacy ES modules field
"main", // CommonJS fallback
"browser" // Browser-specific builds (if applicable)
],
});
Package Compatibility
This configuration works particularly well with modern packages that provide dual builds:
zod
: 28% smaller bundles with better tree shakingdate-fns
: Significantly smaller when using ES module versionramda
: Better dead code elimination with ES modulesuuid
: Cleaner imports without CommonJS wrappers
The key benefit is that you get the most optimized version of each dependency without having to manually specify different import paths or worry about which format each package uses.
π¦ Use ESM for the Lambda Project
ES modules (ESM) provide static import/export analysis that enables better tree shaking than
CommonJS. Unlike CommonJSβs dynamic require()
calls, ESMβs static structure allows ESBuild
to determine exactly which exports are used at build time.
Configure your project for ESM by setting "type": "module"
in package.json and using
format: 'esm'
in ESBuild (as shown in the optimized configuration).
π Bundle Size Comparison
For a Lambda function that imports 1 function from a utility file containing 3 functions:
CommonJS: All 3 functions bundled (~8.5KB) π¦ (the βeverything must come with usβ approach)
ESM: Only the used function bundled (~3.2KB, 62% smaller) π― (the βpack light, travel smartβ approach)
ESMβs static analysis allows ESBuild to eliminate unused functions entirely.
π For Libraries, set files
in package.json
When creating shared libraries or npm packages used by Lambda functions, the files
field in
package.json
controls which files are included in the published package. By default, npm
includes many unnecessary files that bloat package size and slow down installation. Explicitly
defining the files
array ensures only essential files are bundled.
The Problem with Default Packaging
Without the files
field, npm includes everything except whatβs in .npmignore
:
# Default npm package contents
my-utility-lib/
βββ src/ # Source TypeScript files (unnecessary)
βββ tests/ # Test files (unnecessary)
βββ docs/ # Documentation (unnecessary)
βββ .github/ # CI configuration (unnecessary)
βββ examples/ # Example code (unnecessary)
βββ dist/ # Built output (NEEDED)
βββ package.json # Package metadata (NEEDED)
βββ README.md # Basic docs (NEEDED)
βββ LICENSE # License (NEEDED)
# Package size: 2.3MB
Optimized Library Configuration
Use the files
field to include only essential files:
{
"name": "my-utility-lib",
"version": "1.0.0",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "esbuild src/index.ts --bundle --outdir=dist --format=cjs,esm"
}
}
Real Example: Utility Library
Consider a shared validation library used across multiple Lambda functions:
// src/validators.ts
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validatePhoneNumber(phone: string): boolean {
return /^\+?[\d\s-()]+$/.test(phone);
}
export function sanitizeInput(input: string): string {
return input.trim().replace(/[<>]/g, '');
}
Without files
Configuration
The package includes all development files:
# Published package contents
validation-utils-1.0.0.tgz
βββ src/
β βββ validators.ts # 2.1KB (source code)
β βββ types.ts # 0.8KB (type definitions)
β βββ utils.ts # 1.3KB (helper functions)
βββ tests/
β βββ validators.test.ts # 5.2KB (test files)
β βββ setup.ts # 0.9KB (test setup)
βββ docs/
β βββ api.md # 3.1KB (documentation)
β βββ examples.md # 2.8KB (examples)
βββ dist/
β βββ index.js # 1.4KB (built output - NEEDED)
β βββ index.mjs # 1.2KB (ESM output - NEEDED)
β βββ index.d.ts # 0.6KB (type definitions - NEEDED)
βββ package.json # 0.5KB (NEEDED)
βββ README.md # 1.1KB (NEEDED)
βββ LICENSE # 1.0KB (NEEDED)
# Total package size: 22.0KB
# Useful content: 5.8KB (26% efficiency)
With Optimized files
Configuration
Only essential files are included:
# Optimized package contents
validation-utils-1.0.0.tgz
βββ dist/
β βββ index.js # 1.4KB (built output)
β βββ index.mjs # 1.2KB (ESM output)
β βββ index.d.ts # 0.6KB (type definitions)
βββ package.json # 0.5KB (metadata)
βββ README.md # 1.1KB (basic docs)
βββ LICENSE # 1.0KB (license)
# Total package size: 5.8KB (74% reduction)
# Useful content: 5.8KB (100% efficiency)
Impact on Lambda Deployments
This optimization compounds across dependencies:
# Before optimization (typical shared libraries)
npm install my-validation-lib my-date-utils my-crypto-helpers
βββ my-validation-lib: 22.0KB
βββ my-date-utils: 18.5KB
βββ my-crypto-helpers: 31.2KB
Total dependency size: 71.7KB
# After optimization
npm install my-validation-lib my-date-utils my-crypto-helpers
βββ my-validation-lib: 5.8KB
βββ my-date-utils: 4.2KB
βββ my-crypto-helpers: 7.1KB
Total dependency size: 17.1KB (76% reduction)
Best Practices for the files
Field
Include only whatβs necessary for runtime:
{
"files": [
"dist/", // Built JavaScript/TypeScript output
"lib/", // Alternative build output directory
"README.md", // Basic documentation
"LICENSE", // License information
"CHANGELOG.md" // Version history (optional)
]
}
Exclude development and build artifacts:
- Source TypeScript files (
src/
) - Test files (
tests/
,__tests__/
) - Documentation (
docs/
) - Configuration files (
.eslintrc
,tsconfig.json
) - CI/CD files (
.github/
,.circleci/
)
Smaller library packages mean faster npm install
times, reduced storage costs in CI/CD
pipelines, and ultimately smaller Lambda deployment packages when dependencies are bundled.
π Analyze Your Bundle
Understanding whatβs inside your Lambda bundle is crucial for optimization. ESBuildβs built-in bundle analyzer helps you identify which dependencies are consuming the most space (spoiler: itβs probably that one package you imported for a single function), spot potential duplication, and find opportunities for further optimization.
Generate Bundle Metadata
The metafile: true
option (shown in the optimized configuration) generates a meta.json
file
containing detailed information about your bundle composition.
Save Metadata to File
For easier analysis, write the metadata to a dedicated file:
// esbuild.config.js
import { build, analyzeMetafile } from 'esbuild';
import fs from 'fs';
const result = await build({
entryPoints: ['src/handler.ts'],
bundle: true,
minify: true,
metafile: true,
outfile: 'dist/handler.js',
platform: 'node',
target: 'node24',
});
// Save metadata for analysis
fs.writeFileSync('meta.json', JSON.stringify(result.metafile, null, 2));
// Optional: Generate text analysis
const analysis = await analyzeMetafile(result.metafile);
console.log(analysis);
Analyze with ESBuildβs Online Tool
Upload your meta.json
file to the ESBuild Bundle Analyzer
for interactive visualization. The analyzer provides three different views:
π Treemap Chart: Shows proportional sizes as rectangles
- Larger rectangles = bigger dependencies
- Quickly identify your largest dependencies
- Spot unexpected large packages
π Sunburst Chart: Displays hierarchical relationships
- Center-out visualization of dependency tree
- See how modules relate to each other
- Identify deeply nested dependencies
π₯ Flame Chart: Timeline-style breakdown
- Linear view of module sizes
- Good for sequential analysis
- Easy to scroll through dependencies
Reading Your Bundle Analysis
Hereβs what to look for in your analysis:
# Example bundle breakdown
π¦ dist/handler.js (245 KB)
βββ ποΈ node_modules
β βββ π @aws-sdk/client-dynamodb (89 KB) β οΈ Largest dependency
β βββ π zod (23 KB)
β βββ π uuid (8 KB)
β βββ π lodash (67 KB) β οΈ Consider lodash-es for tree shaking
βββ ποΈ src
β βββ π handler.ts (12 KB)
β βββ π utils/validators.ts (4 KB)
β βββ π types.ts (2 KB)
βββ π Other (40 KB)
Optimization Opportunities to Identify
Use the analyzer to find:
Unexpectedly Large Dependencies (the βhow did this get so big?β moment):
// Before: Using full AWS SDK v2 (brings every AWS service you'll never use)
import AWS from 'aws-sdk';
const dynamodb = new AWS.DynamoDB();
// Bundle impact: ~2.1MB (ouch!)
// After: Using specific AWS SDK v3 client (much more civilized)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({});
// Bundle impact: ~89KB (96% reduction - now we're talking!)
Duplicate Dependencies:
- Different versions of the same package
- Similar packages doing the same thing
- Polyfills that arenβt needed for your Node.js target
Unnecessary Code:
- Test files accidentally included
- Development utilities in production builds
- Unused exports from large libraries
Script for Automated Analysis
Create a build script that includes analysis:
// scripts/build-and-analyze.js
import { build, analyzeMetafile } from 'esbuild';
import fs from 'fs';
async function buildWithAnalysis() {
const result = await build({
entryPoints: ['src/handler.ts'],
bundle: true,
minify: true,
metafile: true,
outfile: 'dist/handler.js',
platform: 'node',
target: 'node24',
});
// Save for online analysis
fs.writeFileSync('dist/meta.json', JSON.stringify(result.metafile, null, 2));
// Print text summary
const analysis = await analyzeMetafile(result.metafile, { verbose: true });
console.log(analysis);
// Alert on large bundles
const bundleSize = fs.statSync('dist/handler.js').size;
if (bundleSize > 1024 * 1024) { // > 1MB
console.warn(`β οΈ Large bundle detected: ${(bundleSize / 1024 / 1024).toFixed(1)}MB`);
}
console.log(`β
Bundle generated: ${(bundleSize / 1024).toFixed(1)}KB`);
console.log(`π Upload dist/meta.json to https://esbuild.github.io/analyze/ for visualization`);
}
buildWithAnalysis().catch(console.error);
Regular bundle analysis helps you catch size regressions early and continuously optimize your Lambda functions for better performance and lower costs. Think of it as a regular health checkup for your code - except instead of checking your cholesterol, youβre checking your bundle bloat! π©Ί