I'm a big fan of Serverless Framework as it takes care of a lot of things behind the scenes and offers many plugins. I do my work in Typescript and I was using serverless-plugin-typescript that takes care of transpiling into Javascript. Next to that, I was using a combination of serverless-plugin-common-excludes and a huge custom exclude list to reduce the size of the package (zip files) being uploaded to Lambda.
Doing a deploy would take around 2 minutes (on a Intel Core i5-13600K processor with 32GB RAM for who is interested) and in my project resulted in a 4MB package. While 4MB isn't a lot, it still contained a lot of code which wasn't actually used. The cold start time of the Lambda was around 4 seconds which bothered me a lot. While this seems to be caused by an unresolved issue at AWS with the node 18 runtime, I felt like I shouldn't sit around and wait for a solution.
When looking for options to reduce the package size I stumbled upon the serverless-esbuild plugin which uses esbuild which is supposed to be super fast (spoiler alert: it is!). While the plugin works without configuration it felt like it was capable of more.
Goals
I hoped to achieve the following things:
Fast builds
While local invocation and tools like LocalStack are awesome, I prefer to just upload to AWS and run everything on the actual (dev) environment. Waiting for a deploy of several minutes can be really annoying so the faster it builds, the faster I can work.
Small package size
Having a small package size has multiple advantages:
- Faster deployments
- Lower cold start time
Proper stack traces
When something goes wrong unexpectedly and you go to CloudWatch it's very painful to only see unreadable stack traces because of bundled, minified code. This is where source maps come in. Source maps can be used to transform bundled code back into their original file locations and line numbers.
The problem I faced with source maps is that once generated, they were huge (1 MB code, 8 MB source map) as it included all the data from all external packages while usually the errors are within my own code. In case something went wrong, it would take around 4 seconds to load and process the source map to generate the stack trace, that was unacceptable to me as Lambda is paid for by the millisecond. The slow processing of source maps seems to be an issue from nodejs itself.
Result
After fiddling around with configurations and reading many issues on Github I eventually ended up with the following configuration that seems to tick off the boxes for me:
Configuration
serverless.yaml
plugins:
- serverless-esbuild # enables esbuild plugin
package:
individually: true # an optimized package per function
custom:
esbuild:
config: './esbuild.config.cjs' # external config file
environment:
NODE_OPTIONS: '--enable-source-maps' # use source map if available
esbuild.config.cjs
const {Buffer} = require('node:buffer');
const fs = require('node:fs');
const path = require('node:path');
// inspired by https://github.com/evanw/esbuild/issues/1685
const excludeVendorFromSourceMap = (includes = []) => ({
name: 'excludeVendorFromSourceMap',
setup(build) {
const emptySourceMap = '\n//# sourceMappingURL=data:application/json;base64,' + Buffer.from(JSON.stringify({
version: 3,
sources: [''],
mappings: 'A',
})).toString('base64');
build.onLoad({filter: /node_modules/u}, async (args) => {
if (/\.[mc]?js$/.test(args.path)
&& !new RegExp(includes.join('|'), 'u').test(args.path.split(path.sep).join(path.posix.sep))
) {
return {
contents: `${await fs.promises.readFile(args.path, 'utf8')}${emptySourceMap}`,
loader: 'default',
};
}
});
},
});
module.exports = () => {
return {
format: 'esm',
minify: true,
sourcemap: true,
sourcesContent: false,
keepNames: false,
outputFileExtension: '.mjs',
plugins: [excludeVendorFromSourceMap(['@my-vendor', 'other/package'])],
banner: {
// https://github.com/evanw/esbuild/issues/1921
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
}
};
};
What this does:
- Targets the node version as configured in Serverless Framework (in my case: node 18)
- Package every function into a separate, optimized single file using tree shaking and minification
- Using ESM (instead of CJS) allowing things like top level await which can be very useful
- Create empty source maps for external packages except for manually enabled ones to prevent having a giant source map
- Includes a "banner" to support packages that don't support ESM yet.
- Works on Windows and *nix machines
The Good
- Extremely fast build process - it only takes miliseconds to transpile Typescript and package
- Really small package size
- Cold start went from around 4 seconds to 1.5 second, a massive improvement!
- Readable stack traces for my own source code and manually enabled vendor code
The Less Good
- Even with a smaller source map it still takes node quite some time to process an error - this delay is around 100ms - 400ms for my use cases. Not super fast but way more acceptable than the 4 to 5 seconds without optimization obviously.
- If something unexpected now happens within vendor code, it won't show up in the stack trace unless it was configured to be included in the source maps.
Verdict
While I was a bit skeptical at first to switch to esbuild, I'm quite happy with the end result. I hope this helps someone else to find a good configuration for their use case. If you have some tips or tricks that I've missed, please share them in the comments.
Top comments (3)
I independently came across essentially the same config, but I noticed it wasn't doing tree shaking on the aws sdk v3 when I looked at the metadata using the esbuild bundle size analyzer. To enable tree shaking I needed the following additional configuration:
I found this missing piece reading this GitHub Issue
Looking at the source map, I found it included the json files from the node_modules. From this GitHub Issue issue I added this to my
excludeVendorFromSourceMap
plugin. I found that if I didn't used the js loader for the.json
files inresources
, I encountered errors with the xray sdk like this:✖ Error: Error in sampling file. Invalid attribute for default: default. Valid attributes for default are "fixed_target" and "rate".
So I needed to use thecopy
loader for those files.For me the
excludeVendorFromSourceMap
plugin did not reduce the size of the source map. After a bit of fiddling around I found the reason: myincludes
array is empty, causing it to match against an empty regex that matches anything. I have modified the code to check for that case:Great insights.
Thanks for sharing.