Skip to content Cajun Code Monkey Logo
Cajun
Code
Monkey

Optimize TypeScript Bundles for AWS Lambda with ESBuild

Posted on:August 19, 2025Β atΒ 08:00 AM

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:

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:

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:

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

πŸŒ… Sunburst Chart: Displays hierarchical relationships

πŸ”₯ Flame Chart: Timeline-style breakdown

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:

Unnecessary Code:

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! 🩺