Should you minify Lambda code?

Published on April 12, 2024

I thought minimizing Lambda function code was a best practice for Lambda optimization - it’s even recommended by AWS in a not-so-old blog post. Turns out, I was wrong.

Contrary to expectations, minification not only does not decrease cold starts but can also increase function latency.

If you know what minification and source maps are, go ahead and jump straight to the performance tests.

Why Minify?

Code minification removes whitespaces and shrinks names and syntax to output shorter but still functional code. This is a standard procedure in web development, where smaller files mean faster downloads.

But why do this for Lambda functions? The argument is that the smaller bundle size reduces cold starts.

For example, minification will change this beautiful code:

export const handler = async () => {
  const message = 'Hello from Lambda!';
  return {
    statusCode: 200,
    body: JSON.stringify({message}),
  };
}

into:

var e=async()=>({statusCode:200,body:JSON.stringify({message:"Hello from Lambda!"})});export{e as handler};

which is shorter by 45 characters or 30%.

So, will it reduce the duration of our Lambda cold starts by 30%? Or by any amount, at least?

Well, probably not.

Source Map - The Partner in Crime

You see, minification has a pretty big drawback - it’s also code obfuscation. Our code ceases to be readable. But do we need to read the deployed version of the code?

Well, sometimes we need - when our Lambda throws an error.

Let’s say we have this Lambda function:

index.ts
type User = { profile: { name: string } };

export const handler = async (user: User) => {
  return generateResponse(user);
}

const generateResponse = (user: User) => {
  return {
    statusCode: 200,
    body: JSON.stringify({message: generateGreeting(user)}),
  };
}

const generateGreeting = (user: User) => {
  return `Hello ${user.profile.name}`;
}

Oh no, we made a classic mistake - we didn’t validate the input data. But the error should be easy to debug, right?

It is - without minification:

{
  "errorType": "TypeError",
  "errorMessage": "Cannot read properties of undefined (reading 'name')",
  "trace": [
    "TypeError: Cannot read properties of undefined (reading 'name')",
    "    at generateGreeting (file:///var/task/index.mjs:14:32)",
    "    at generateResponse (file:///var/task/index.mjs:10:37)",
    "    at Runtime.handler (file:///var/task/index.mjs:5:10)",
    "    at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1173:29)"
  ]
}

We see that the error was thrown in generateGreeting() function in line 14, which was called by generateResponse(), which was called by the handler().

But with minification, the stack trace becomes… cryptic:

{
  "errorType": "TypeError",
  "errorMessage": "Cannot read properties of undefined (reading 'name')",
  "trace": [
    "TypeError: Cannot read properties of undefined (reading 'name')",
    "    at n (file:///var/task/index.mjs:2:105)",
    "    at r (file:///var/task/index.mjs:2:72)",
    "    at Runtime.s [as handler] (file:///var/task/index.mjs:2:16)",
    "    at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1173:29)"
  ]
}

Hm… The error was thrown somewhere by some function…

Of course, there is a solution: source maps.

Source maps are generated during minification as a map from the minified code back to our readable source. With source maps, the error trace is almost identical to the original one. So, how do we use them?

In web development, you can import or point to the source map in the browser developer tools.

However, with Lambda functions, the practice is to bundle the source map together with the code. Then, on error, Node.js can read the source map and give you the “translated” trace.

This leads us to two possible problems:

  • Bundle size - how big is the generated source map?
  • Performance - does this translation on error increase latency?

Performance Testing of Lambda Bundles

Let’s run a test. We will have 4 Lambda functions with the same settings:

  • Node.js 20 runtime
  • ARM architecture
  • 256 MB memory
  • code bundled as ECMAScript modules (ESM)
  • 4 pretty large libraries: zod, lodash, winston, and axios
  • 200 ms sleep to imitate processing and increase number of cold starts

You should never import whole libraries like lodash, only individual modules, to enable tree shaking. But here, we want to see the results of minification, so we need something to minify.

Functions will vary in bundling configuration:

  • Lambda 1: no minification, no source map
  • Lambda 2: minified, no source map
  • Lambda 3: minified, with source map parsed by Node.js
  • Lambda 4: minified, with source map parsed by the source-map-support lib

In CDK:

new NodejsFunction(this, 'TestLambda', {
  entry: 'index.ts',
  runtime: lambda.Runtime.NODEJS_20_X,
  architecture: lambda.Architecture.ARM_64,
  memorySize: 256,
  timeout: cdk.Duration.seconds(5),
  bundling: {
    target: "node20",
    // minify: true,           // Lambdas 2-4
    // sourceMap: true,        // Lambdas 3-4
    // sourcesContent: false,  // Lambdas 3-4
  mainFields: ["module", "main"],
  format: nodejs.OutputFormat.ESM,
  banner: "const require = (await import('node:module')).createRequire(import.meta.url);",
  },
  environment: {
    // NODE_OPTIONS: "--enable-source-maps", // Lambda 3
  },
});

We will test two cases:

  • successful execution
  • error thrown

One Artillery script later…

No minificationMinified, no source mapMinified, source map, Node.js1Minified, source map, lib2

Size (kB)
📦 Bundle size3251.7161.2378.1
- index.mjs41174.2511.4
- index.mjs.map5--710.3

Time (ms)
❄️ Cold start
- median277278283276
- p95298298307303
Invocation duration with success (after ❄️ cold start)
- median205205205205
- p95206206206206
Invocation duration with success (🔥 warm)
- median202202202202
- p95206204205205
Invocation duration with error (after ❄️ cold start)
- median23421515522830
- p9524922918343014
Invocation duration with error (🔥 warm)
- median202202202203
- p95204204205207

1 Source map parsing by Node.js.
2 Source map parsing by source-map-support library.
3 Size of .zip package uploaded to S3 and downloaded by Lambda to run.
4 Unpacked file size.
5 Unpacked source map file size.

Update: Please note those cold starts reflect only the initialization time, which does not include the time needed to download the package from S3. This will be updated.

The project to run the tests is available on GitHub:

aws-lambda-minification-test
Testing Lambda performance with and without code minification.
0 0

Test Conclusions

Conclusion 1

After minifying and adding the source map to the bundle, the output size is bigger than before the minification. This is true also when more or less libraries are bundled with code.

This negates any potential gains from the minification itself.

Conclusion 2

Even worse, when an error is thrown for the first time in a runtime, the overhead to parse the source map can be huge. In this case, an additional 1-1.5 seconds is needed just to log the error trace with the same info we would have without minifying in the first place.

And remember, the error will be returned to the user at the end of the invocation. So the client will first see the increased response latency and then the error…

Conclusion 3

Node.js native source map support is far better than the source-map-support library, which is good because it wasn’t always like this. However, the performance impact is still massive.

But Does the (Bundle) Size Matter?

Depends on who you ask, I guess.

But seriously? No. Well, yes.

Let’s just check.

I’ve created three Lambda functions of different sizes. Each includes a simple handler and a binary file of random data.

The same Lambda settings, different deployed package sizes:

0 MB100 MB250 MB
❄️ Median cold start (ms)149140147
❄️ p95 cold start (ms)166149176

Not what you expected?

Let’s make another test. The two following functions are packaged with a node_modules directory containing the same four libraries we had in earlier tests. No bundling with esbuild this time. Just straight npm install and deployment of the zipped directory.

Bundle size: 2.1 MB. Unzipped: 11 MB.

We run it in two variants.

In the first, the handler is just:

export const handler = async () => {
  return {statusCode: 200};
};

In the second, we actually import the libraries:

import {z} from "zod";
import * as _ from "lodash";
import axios from "axios";
import * as winston from "winston";

export const handler = async () => {
  return {statusCode: 200};
};

Results:

2.1 MB, packages not used2.1 MB, imported packages
❄️ Median cold start (ms)146414
❄️ p95 cold start (ms)163449

Conclusion?

The cold start is affected by the size of loaded modules, not the size of the package. The majority of the cold start is not downloading the package from the S3; it’s Node.js loading the code.

And the amount of code to load into memory stays the same whether it’s minified or not.

Update: Please note that, similarly to the above, those cold starts do not include the time needed to download the package from S3. This will be updated.

Lessons Learned - Do’s and Don’ts

Don’t:

  • Minify your Lambda function code.
  • Include source maps in the bundle.

Those “optimizations” have the opposite effect.

Do:

  • Limit the number of large libraries you use in Lambda functions.
  • Use ECMAScript modules where possible and import only the parts you use, so they can be tree shaked.
  • Test any optimizations for their actual impact.

What matters for cold start performance is the total amount of code in the Lambda function - yours and libraries.

On the other hand, it’s important to remember that cold starts usually are not as big of a problem. In most applications, they affect around 1% of invocations. But if you want to optimize or follow good practices, those improvements actually work.

And if you have a case where minifying brings improvements - please share it in the comments. I would love to hear it!