How to Use Source Maps in TypeScript Lambda Functions (with Benchmarks)
Tags: lambda, aws, typescript, serverless
Published: 2022-01-24 11:50:51

TypeScript is a popular language for developers of all kinds and it's made its mark on the serverless space. Most of the major Lambda frameworks now have solid TypeScript support. The days of struggling with webpack configurations are mostly behind us.

## Table of Contents
* <a href="#stack-traces">Stack Traces</a>
* <a href="#emitting-source-maps">Emitting Source Maps</a>
* <a href="#source-map-support">Source Map Support</a>
* <a href="#cdk-example">CDK Example</a>
  * <a href="#sst">Serverless Stack</a>
* <a href="#sam-example">SAM Example</a>
  * <a href="#architect">Architect</a>
* <a href="#sls-example">Serverless Framework Example</a>
* <a href="#benchmarks">Benchmarks</a>
  * <a href="#not-minified-no-source-maps">Not Minified without Source Maps</a>
  * <a href="#minified-no-source-maps">Minified without Source Maps</a>
  * <a href="#minified-with-source-maps">Minified with Source Maps</a>
  * <a href="#error-minified-no-source-maps">Error Minified without Source Maps</a>
  * <a href="#error-minified-with-source-maps">Error Minified with Source Maps</a>
* <a href="#conclusion">Conclusion</a>

## Stack Traces<a name="stack-traces"/>

If we're already transpiling our code to convert the TypeScript source to Lambda-friendly JavaScript, we might as well go ahead and [minify](https://esbuild.github.io/api/#minify) and [tree-shake](https://esbuild.github.io/api/#tree-shaking) the code as well. Smaller bundles can make deployments faster and may even help with cold start and execution time. However, this can make debugging difficult when we start seeing stack traces that look like this:

```json
{
    "errorType": "SyntaxError",
    "errorMessage": "Unexpected end of JSON input",
    "stack": [
        "SyntaxError: Unexpected end of JSON input",
        "    at JSON.parse (<anonymous>)",
        "    at VA (/var/task/index.js:25:69708)",
        "    at Runtime.R8 [as handler] (/var/task/index.js:25:69808)",
        "    at Runtime.handleOnce (/var/runtime/Runtime.js:66:25)"
    ]
}
```

The error message here lets us know that we're failing to parse a JSON string, but the stack trace itself is useless for finding the line where the code is failing. We'll have no choice but to look through our code and hope to find the error. We might be able to do a text search for `JSON.parse` but if that is happening in one of our dependencies, searching won't work. What's next? Add a bunch of log statements to the code? No! If we use Source Maps, we can get more useful stack traces:

```json
{
    "errorType": "SyntaxError",
    "errorMessage": "Unexpected end of JSON input",
    "stack": [
        "SyntaxError: Unexpected end of JSON input",
        "    at JSON.parse (<anonymous>)",
        "    at VA (/fns/db.ts:39:8)",
        "    at Runtime.R8 (/fns/list.ts:6:24)",
        "    at Runtime.handleOnce (/var/runtime/Runtime.js:66:25)"
    ]
}
```

Now we can see the stack includes a call from [line 6](https://github.com/elthrasher/lambda-sourcemaps/blob/main/fns/list.ts#L6) of `list.ts` which finally fails on [line 39](https://github.com/elthrasher/lambda-sourcemaps/blob/main/fns/db.ts#L39) of `db.ts`.

To enable Source Maps in our application, we need to tell our build tool to emit Source Maps and we need to enable Source Map support in our runtime.

## Emitting Source Maps<a name="emitting-source-maps"></a>

Emitting Source Maps is very easy with [esbuild](https://esbuild.github.io/). We simply set the boolean property in our [configuration](https://esbuild.github.io/api/#sourcemap). Now when we run the build, we'll get an `index.js.map` file as well as our `index.js`. This file must be uploaded to the Lambda service. We'll see how to do that with AWS CDK, AWS SAM and the Serverless Framework a bit later in this article.

## Source Map Support<a name="source-map-support"></a>

Having the `index.js.map` file in our Lambda runtime isn't sufficient to enable Source Maps. We also have to make sure the runtime knows to make use of them. Fortunately this is very easy ever since [Node.js version 12.12.0](https://nodejs.org/dist/latest-v12.x/docs/api/cli.html#cli_enable_source_maps). We just have to set the `--enable-source-maps` command line option. Command line options can be set in AWS Lambda by setting the `NODE_OPTIONS` environment variable. At the time of this writing, AWS Lambda supports Node.js versions 12 and 14. AWS does not publish the minor versions in use in Lambda, but we can discover it by logging out `process.version` in a function. As of late January, 2022, the Node.js versions in use in Lambda in us-east-1 are `v12.22.7` and `v14.18.1` so we'll have no trouble using Source Maps.

If we needed to enable Source Maps in a runtime that doesn't support the native version, we could always use [Source Map Support](https://github.com/evanw/node-source-map-support).

## CDK Example<a name="cdk-example"></a>

All example code is available on [GitHub](https://github.com/elthrasher/lambda-sourcemaps).

AWS CDK is my preferred tool for writing and deploying serverless applications, in part because of the [aws-lambda-nodejs](https://dev.to/elthrasher/aws-cdk-aws-lambda-nodejs-module-9ic) construct. This construct makes it very easy to work with TypeScript. It wraps esbuild and exposes options. It also supports setting environment variables.

When I'm working with multiple Lambda functions, I often find it helpful to create a single props object that then gets shared among multiple functions.

```typescript
const lambdaProps = {
  architecture: Architecture.ARM_64,
  bundling: { minify: true, sourceMap: true },
  environment: {
    NODE_OPTIONS: '--enable-source-maps',
  },
  logRetention: RetentionDays.ONE_DAY,
  runtime: Runtime.NODEJS_14_X,
  memorySize: 512,
  timeout: Duration.minutes(1),
};

new NodejsFunction(this, 'FuncOne', {
  ...lambdaProps,
  entry: `${__dirname}/../fns/one.ts`,
});

new NodejsFunction(this, 'FuncTwo', {
  ...lambdaProps,
  entry: `${__dirname}/../fns/two.ts`,
});
```

As we can see, it's very simple to enable Source Maps when already using AWS CDK and NodejsFunction.

### Serverless Stack<a name="sst">

[Serverless Stack](https://serverless-stack.com/) is a very cool value add that builds on top of AWS CDK delivering an awesome developer experience and dashboard. SST's own version of NodejsFunction, [Function](https://docs.serverless-stack.com/constructs/Function) automatically emits source maps. Their use can be enabled simply by [setting NODE_OPTIONS as described](https://docs.serverless-stack.com/advanced/source-maps).

## SAM Example<a name="sam-example"></a>

SAM support for TypeScript has been lagging for some time, but a [pull request](https://github.com/aws/aws-lambda-builders/pull/307) was just merged that should change that. It looks like we'll be able to add an [aws_sam](https://github.com/aws/aws-lambda-builders/blob/develop/aws_lambda_builders/workflows/nodejs_npm/DESIGN.md#activating-the-bundler-workflow) key in the package.json file to enable building within the SAM engine as part of `sam build`. Although this PR has been merged to aws-lambda-builders, the engine behind `sam build`, it will still need to be added to [aws-sam-cli](https://github.com/aws/aws-sam-cli) and released (to much fanfare, one expects) before it can be used with SAM.

Meanwhile - or if we're considering other options for deploying our functions - we can add an extra build step. We'll create an `esbuild.ts` [file](https://github.com/elthrasher/lambda-sourcemaps/blob/main/sam/esbuild.ts) that transpiles the functions and then point our SAM `template.yaml` file at the output of that step.

```typescript
import { build, BuildOptions } from 'esbuild';

const buildOptions: BuildOptions = {
  bundle: true,
  entryPoints: {
    ['create/index']: `${__dirname}/../fns/create.ts`,
    ['delete/index']: `${__dirname}/../fns/delete.ts`,
    ['list/index']: `${__dirname}/../fns/list.ts`,
  },
  minify: true,
  outbase: 'fns',
  outdir: 'sam/build',
  platform: 'node',
  sourcemap: true,
};

build(buildOptions);
```

SAM templates will take everything in the `CodeUri` so the above code will transpile a TypeScript file at `fns/list.ts` and output `sam/build/list/index.js` and `sam/build/list/index.js.map`. By setting `CodeUri: sam/build/list`, SAM will package the two files and upload them to Lambda.

Now we just need to set the environment variable. This is easy enough to do in a SAM template. We can set it as a global so it only needs to be in the [template](https://github.com/elthrasher/lambda-sourcemaps/blob/main/template.yaml) once

```yaml
Globals:
  Function:
    Environment:
      Variables:
        NODE_OPTIONS: '--enable-source-maps'
```

In order to make sure we always build before deploying, we can add some [npm scripts](https://github.com/elthrasher/lambda-sourcemaps/blob/main/package.json#L6).

```json
"scripts": {
  "build:lambda": "npm run clean && ts-node --files sam/esbuild.ts",
  "clean": "rimraf cdk.out sam/build",
  "deploy:sam": "npm run build:lambda && sam deploy --template template.yaml",
  "destroy:sam": "sam delete"
}
```

This works well enough, but does take some extra effort. SAM users are no doubt eagerly awaiting better TypeScript support.

### Architect<a name="architect"></a>

Alternately, use [Architect](https://arc.codes/). Architect is a 3rd party developer experience that builds on top of AWS SAM. Architect includes a [TypeScript plugin](https://github.com/architect/plugin-typescript).

## Serverless Framework Example<a name="sls-example"></a>

The [Serverless Framework](https://www.serverless.com/) is known for its plugin system. [serverless-esbuild](https://floydspace.github.io/serverless-esbuild/) brings bundling and all the options we need to support Source Maps in TypeScript into the `sls package` and `sls deploy` commands.

The plugin is configured in our [`serverless.yml`](https://github.com/elthrasher/lambda-sourcemaps/blob/main/template.yaml) file.

```yaml
custom:
  esbuild:
    bundle: true
    minify: true
    sourcemap: true
```

And then we just point our functions at our TypeScript handlers.

```yaml
functions:
  create:
    handler: fns/create.handler
```

Much like SAM, Serverless lets us set global environment variables for our functions.

```yaml
provider:
  name: aws
  lambdaHashingVersion: 20201221
  runtime: nodejs14.x
  environment:
    NODE_OPTIONS: '--enable-source-maps'
```

Much like AWS CDK, this is a good experience for TypeScript developers. Those already using Serverless Framework should have an easy time adding Source Maps to their applications.

## Benchmarks<a name="benchmarks"></a>

There's a lot of guidance against using Source Maps in production because of a supposed negative performance impact. Let's measure the admittedly-simple `list` function and see if minification or the use of source maps has any noticeable effect.

I used [autocannon](https://github.com/mcollina/autocannon) to test the function at 100 concurrent executions for 30 seconds. I also used [Lambda Power Tuning](https://github.com/alexcasalboni/aws-lambda-power-tuning) to find the ideal memory configuration, which proved to be 512MB. All the results are [available](https://github.com/elthrasher/lambda-sourcemaps/blob/main/BENCHMARKS.md).

### Not Minified without Source Maps<a name="not-minified-no-source-maps"></a>

The unminified function is 1.2MB in size. This is mostly from [`@aws-sdk/client-dynamodb`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/index.html) and [`@aws-sdk/lib-dynamodb`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_lib_dynamodb.html). Whether sticking with SDK v2 might be better performance would be a good topic for another post. This function is over one MB, despite a small amount of custom code.

Running the test, the function has an average execution of 46.99ms and a max of 914ms. 99% of my requests are at or below 90ms.

### Minified without Source Maps<a name="minified-no-source-maps"></a>

Minifying the function drops the size to 534.8kb, less than half the size. My test showed an average response of 47.83ms, a max of 1010ms and 90ms at the 99th percentile. This is slightly worse, but not statistically significant. I expect if I ran these tests over and over again, I would see that minifying the code has no real effect on performance. This is not a surprise. 1.2MB is still fairly small and I don't expect to see much in the way of added latency at that size.

### Minified with Source Maps<a name="minified-with-source-maps"></a>

The minified function is still 534.8kb, of course, but the Source Map is 1.5MB so this will be the largest upload, not that ~2MB is a lot or will significantly slow down our deployments.

The average response time for this test is 46.52ms, max is 968mb and the 99th percentile is 82ms. This is the best result so far, but not statistically significant. I would say this is truly a three-way tie which tells us that adding Source Maps to this function did not increase latency.

That's because of the native support in Node.js! Since no stack traces were emitted, the Source Maps were never referenced. It's possible if we had to rely on a library for this capability, we wouldn't have the same outcome.

### Error Minified without Source Maps<a name="error-minified-no-source-maps"></a>

Will triggering error conditions make a difference? Let's see. I ran the same test but this time the function has that `JSON.parse` error in it. This happens before the call to DynamoDB, so we can expect it to be a little faster than the successful function. We get 37.86ms as the average, 1004ms as the max and 58ms at 99%. It's impressive the call to DynamoDB only seems to add about 10ms of latency!

### Error Minified with Source Maps<a name="error-minified-with-source-maps"></a>

Enabling Source Maps for the errors does impact performance. Our average has dropped to 97.54ms, max at 1129ms and 99% is 243. This is a significant increase. Source Maps _do_ impact latency when an error occurs. This makes sense and confirms the idea that the Source Maps are only referenced when an error occurs - but now that Source Map must be parsed and that takes time.

## Conclusion<a name="conclusion"></a>

Use Source Maps in production! In my view, if you are getting so many errors that the performance hit from Source Maps is impacting your bottom line, you should probably go and fix those errors. It's very easy to implement Source Maps in a variety of popular Lambda frameworks and they don't impact successful execution. When functions do fail, the useful stack trace is going to be worth the added latency. Developer hours are always going to be more expensive than a few milliseconds of Lambda execution.

[COVER](https://en.wikipedia.org/wiki/Carta_marina)