How JavaScript works: A deep dive into esbuild
This is post #62 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript tool for developers to identify, visualize, and reproduce web app bugs through pixel-perfect session replay.
Introduction
Esbuild is a blazing fast JavaScript bundler built with Golang. Esbuild is a next-generation JavaScript bundler aimed at improving build tool performance and ease of use. And some of its main features are:
- Extreme speed without needing a cache
- ES6 and CommonJS modules
- Tree shaking of ES6 modules
- An API for JavaScript and Go
- TypeScript and JSX syntax
- Source maps
- Minification
- Plugins
Although esbuild does not have a robust feature-set like Webpack, it does its job well — it is easy to use and blazing fast. The image below shows a performance comparison between esbuild and other leading JavaScript bundlers:
According to esbuild’s official website, the above image shows the time to do a production bundle of 10 copies of the three.js library from scratch using default settings, including minification and source maps. More info here.
We can see from the image above that it took esbuild 0.33 seconds to perform this task, and Webpack 5 took 41.53 seconds to perform this task.
While esbuild outperforms other JavaScript bundlers, it is important to mention a downside. And this is because performance is a tradeoff, so while esbuid is lightning-fast, it is not as feature-packed as Webpack.
Also, esbuild has not reached version 1.0, and although esbuild is rising in popularity, it is still less popular than other established bundlers like Webpack and Rollup, as seen in the npm trend below:
However, esbuild’s minimal feature set means it is not as complex as Webpack. esbuild is simple to learn and use, and it is blazing fast.
In addition to the caveats above, esbuild provides APIs for JavaScript, CLI, and Golang.
And in this article, we will learn about esbuild and how to bundle JavaScript applications with it.
Let’s get started in the next section.
Getting Started
Before delving into code, we will learn some esbuild concepts such as the esbuild content types and build API.
Content Types
Esbuild provides built-in support for different content types using loaders. These loaders are similar to Webpack loaders in functionality — they tell esbuild how to parse each content type. Some of these loaders: the TypeScript loader, the JavaScript loader, and the CSS loader are configured by default. And this means esbuild provides out-of-the-box support for these content types.
Below is a list of the content types supported by esbuild:
- Javascript: The JavaScript loader is enabled by default for
.js
,.cjs
, and.mjs
files. - TypeScript: The TypeScript loader is enabled by default for
.ts
,.tsx
,.mts
, and.cts
files. And this enables esbuild to provide built-in support for parsing TypeScript syntax and discarding the type annotations. However, esbuild does not perform type-checking. - JSX: The JSX loader is enabled by default for
.jsx
and.tsx
files. However,JSX
syntax is not enabled in.js
files by default. But we can enable configure this using the build API as seen below:
https://gist.github.com/lawrenceagles/2d53816f6c58b6c34d6e17d0eb0a897e
We will learn more about the esbuild build API in a subsequent section.
- JSON: This loader parses JSON files into JavaScript objects and exports that object by default. It is also enabled by default for
.json
files. - CSS: In esbuild, CSS is first-class content-type and this means esbuild can bundle CSS files directly without needing to import your CSS from JavaScript code. As seen below:
https://gist.github.com/lawrenceagles/2a394cf3da5780a2f558df37a24ca889
So this loader is enabled by default for .css
files and it loads these files as CSS syntax.
- Text: This leader is enabled by default for
.txt
files. The text loader loads the file as a string at build time and exports the string as the default export. And it provides a simple API as seen below
https://gist.github.com/lawrenceagles/bd9b8189dbb08e3d65476fb4e0410a8e - Binary: This loader loads the file as a binary buffer at build time and embeds it into the bundle using Base64 encoding. However, this loader is not enabled by default.
- Base64: This loader loads the file as a binary buffer at build time and embeds it into the bundle as a string using Base64 encoding. This loader is also not enabled by default.
- Data URL: This loader loads the file as a binary buffer at build time and embeds it into the bundle as a Base64-encoded data URL. The data URL loader is useful for bundling images and it can be used in tandem with the CSS loader to load images using
url()
. - This loader is not enabled by default. And to use it, we need to configure it for the appropriate file extension, as seen below:
https://gist.github.com/lawrenceagles/71dbee9cd7393515f8db283db005c75a - External file: This loader copies files to the output directory and embeds the file name into the bundle as a string. And this string is exported using the default export. Similar to the data URL loader this loader can be used to load images and can work together with the CSS loader.
- To use this loader we need to manually configure it for the appropriate extension, as seen below:
https://gist.github.com/lawrenceagles/6c1121af845829b4f8875af454a244eb
And using it looks like this:
https://gist.github.com/lawrenceagles/b568cc5c02930a16d7bd39528782907a
The Build API
Although we can use esbuild via the terminal using the CLI API, if to pass many options to the CLI it can become unwieldy. So for more sophisticated use cases, esbuild also provides a JavaScript API that is the build API. And this allows us to customize the behavior of esbuild. It is synonymous with the webpack.config.js
file for Webpack.
To esbuild build API looks something like this:
https://gist.github.com/lawrenceagles/8be4b1bd951e0b433daf804d3d825d2a
The build function runs the esbuild executable in a child process and returns a promise that resolves when the build is complete.
Although esbuild provides an alternative build API: buildSync
— that runs synchronously it is best to use the async build API because esbuild plugins only work with the asynchronous API.
In an advanced case where we want to support old browsers, we need to transform modern JavaScript syntax into older JavaScript syntax.
We can configure the target environment as seen below:
https://gist.github.com/lawrenceagles/aeca2ca9bcf7869ab92dbd872b9f0c4a
Note the example above uses the buildSync
API.
The esbuild build API provides us with many simple and advanced options for customizing the behavior of esbuild.
And in the code above, we have used some of these options:
- Entry points: This option is an array of files. And each file serves as an input to the bundling algorithm. They are called
entry points
because they are evaluated first, then they load all other code in the app.
So instead of loading many libraries on your page with<script>
tags, we can use theimport
statements to add them to our app’s entry point. - Outfile: This option is only applicable if there is only a single entry point as seen in our example above. The
outfile
option specifies the name of the final bundle — the output file created by the build process. - When there are multiple entry points, we must use the
outdir
option to specify an output directory. - Outdir: This option specifies an output directory for the build process. And this directory will be created only if it does not already exist. For example, the code below would create an output directory called output for the build operation:
https://gist.github.com/lawrenceagles/fea875722e3b92874c71516bc78be45d - Bundle: esbuild does not bundle by default, so to bundle our file we need to explicitly specify it as seen above by setting its option to
true
. - Minify: When set to
true
this option enables the minification of our code during the build process. Minified code is smaller than pretty-printed codes, and they are easier to download. But minified code is more difficult to debug, so usually, we minify code when we are building for production. - Sourcemap: A sourcemap is a file that provides a way to map minified and uglified JavaScript bundle into its unbundled state. During the built state application assets — CSS & JavaScript files get minified and combined into a single bundle to make delivering them from the server more efficient. However, these minified and uglified bundles are difficult to read and debug. Sourcemaps is a file that maps from the bundled source code to the original — unbundled source code thus enabling the browser to reconstruct the unbundled source and deliver it in the debugger. By setting this option to
true
we tell esbuild to generate sourcemaps. - Target: This specifies the target environment — like the browser, for the bundled JavaScript and/or CSS code. So if the JavaScript syntax is too new for the specified environment, it tells esbuild to transform it into older JavaScript syntax that can work in these environments.
Apart from these, there are more options like watch
, serve
, and other advanced options such as tree shaking
, JSX fragments
, JSX factory
, etc.
Bundling with esbuild
In this section, we will learn how to bundle applications with esbuild.
To use esbuild, first, create a nodejs project by running:
npm init -y
From your project directory. Then install the esbuild package by running:
npm install esbuild
You can verify the version by running:
/node_modules/.bin/esbuild — version
And this prints: 0.14.38
We will be bundling a React application so install the following React packages:
npm install react react-dom
Now create an app.jsx
file containing the following code:
https://gist.github.com/lawrenceagles/4829768fab37f3839874610d6504c97a
Now we can tell esbuild to bundle our application using the CLI API by running:
./node_modules/.bin/esbuild app.jsx — bundle — outfile=bundle.js
And we get:
So by running the command above, esbuild bundles our app into a bundle.js
file. Also, esbuild converts the JSX
syntax to JavaScript without any configuration other than the .jsx
extension.
There are two things to note from our example above:
- The esbuild build process does not bundle our app by default, so we explicitly need to pass the
— bundle
flag in the command. And where the— bundle
flag is absent, esbuild would run the Transformation API instead of the build API.
The esbuild transformation API is ideal for environments such as the browser — that don’t have a file system because it performs operations on a single string without access to the file system. And in the case above, by running:./node_modules/.bin/esbuild app.jsx — outfile=bundle.js
The transformation API is called because the— bundle
flag is absent, and our code is transformed into abundle.js
file with the following code:
https://gist.github.com/lawrenceagles/ca983900b7189d075cd807654594fb2e
Thus we can see that the transformation API transformed ourJSX
syntax into pure JavaScript. - The second thing to note is that by default, esbuild does not allow
JSX
syntax in.js
files, so we had to name our fileapp.jsx
. But if we rename ourapp.jsx
file toapp.js
and try to build or transform our app we get an error as seen below:
And while this can be fixed by adding the loader flag: — loader:.js=jsx
to the CLI command, we can also do this using the build API.
So rename the app.jsx
file to app.js
and create a buid.js
file containing the following codes:
https://gist.github.com/lawrenceagles/1c71b91cd981df752d430db3391b4be5
Then update the package.json script as seen below:
https://gist.github.com/lawrenceagles/7981b3be6b5b7dac04fbe9d11fc26490
Now we can build our app by running:npm run build
Also, setting the bundle
option to false
tells esbuild to use the transformation API instead of the build API.
Plugins
Esbuild plugins enable developers to hook into the build process and perform operations. And unlike the build API and the Transformation API, the esbuild plugin API is not available from the CLI. Thus you can only use them via the build API.
And it is important to keep in mind that esbuild plugins do not work with the buildSync
API as mentioned above.
One drawback with using the plugin API is that it is not mature. And accounting for the documentation:
“The plugin API is new and still experimental. It may change in the future before version 1.0.0 of esbuild as new use cases are uncovered. You can follow the tracking issue for updates about this feature.”
With this in mind, various plugins are developed by the community, but these are not officially supported. Here is a list of these plugins.
Conclusion
In the article, we have learned a lot about esbuild the next-generation and blazing fast JavaScript bundler.
Esbuild is minimal featurewise if compared to bundlers like Webpack. However, it trades these features for optimal performance. So this should be the key thing to consider before adopting esbuild.
But this is resolved by using a package like Vite. Vite is a feature-rich Webpack alternative. Vite uses esbuild under the hood — in a smart way. And if you are new to Vite, you can learn all about Vite in our previous article in this series.
Blazing-fast next-generation JavaScript bundlers like esbuild make it much easier for software to have efficient and high-performing code. esbuild has not reached version 1.0 and its API is still evolving. But since we all like to apply new technologies & upgrade our code so even if we feel we’ve tested everything before release, it’s always necessary to verify that our users have a great experience with our product.
A solution like SessionStack allows us to replay customer journeys as videos, showing us how our customers actually experience our product. We can quickly determine whether our product is performing according to their expectations or not. In case we see that something is wrong, we can explore all of the technical details from the user’s browser such as the network, debug information, and everything about their environment so that we can easily understand the problem and resolve it. We can co-browse with users, segment them based on their behavior, analyze user journeys, and unlock new growth opportunities for our applications.
There is a free trial if you’d like to give SessionStack a try.
If you missed the previous chapters of the series, you can find them here:
- An overview of the engine, the runtime, and the call stack
- Inside Google’s V8 engine + 5 tips on how to write optimized code
- Memory management + how to handle 4 common memory leaks
- The event loop and the rise of Async programming + 5 ways to better coding with async/await
- Deep dive into WebSockets and HTTP/2 with SSE + how to pick the right path
- A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
- The building blocks of Web Workers + 5 cases when you should use them
- Service Workers, their life-cycle, and use case
- The mechanics of Web Push Notifications
- Tracking changes in the DOM using MutationObserver
- The rendering engine and tips to optimize its performance
- Inside the Networking Layer + How to Optimize Its Performance and Security
- Under the hood of CSS and JS animations + how to optimize their performance
- Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
- The internals of classes and inheritance + transpiling in Babel and TypeScript
- Storage engines + how to choose the proper storage API
- The internals of Shadow DOM + how to build self-contained components
- WebRTC and the mechanics of peer to peer connectivity
- Under the hood of custom elements + Best practices on building reusable components
- Exceptions + best practices for synchronous and asynchronous code
- 5 types of XSS attacks + tips on preventing them
- CSRF attacks + 7 mitigation strategies
- Iterators + tips on gaining advanced control over generators
- Cryptography + how to deal with man-in-the-middle (MITM) attacks
- Functional style and how it compares to other approaches
- Three types of polymorphism
- Regular expressions (RegExp)
- Introduction to Deno
- Creational, Structural, and Behavioural design patterns + 4 best practices
- Modularity and reusability with MVC
- Cross-browser testing + tips for prerelease browsers
- The “this” variable and the execution context
- High-performing code + 8 optimization tips
- Debugging overview + 4 tips for async code
- Deep dive into call, apply, and bind
- The evolution of graphics
- Dockerizing a Node.js application
- A deep dive into decorators
- Best practices for data compliance
- Proxy and Reflect
- SVG and its use cases (part 1)
- Class static blocks + 6 proposed semantics
- Introduction to Graphs and Trees
- Introduction to PM2, Strongloop, and Forever + 4 tips for Production Process Managers
- Аdvanced SVG capabilities (part 2)
- Тhe publisher-subscriber pattern
- Stacks and Queues + tips for efficient implementation
- Lists vs Blockchain + implementation practices
- The module pattern + comparing CommonJS, AMD, UMD, and ES6 Modules
- The different types of conditional statements + 3 best practices
- The different ways of declaring a function + 5 best practices
- The factory design pattern + 4 use cases
- A guide to build tools + exploring Webpack, Parcel, Rollup, ES Build, and Snowpack
- Building a child process in Node.js
- Streams and their use cases
- Understanding Maps and their use cases + 4 advantages compared to Objects
- A deep dive into Webpack
- How JavaScript works: Recursion in JavaScript, What It Is, and How it is used.
- Implementation of gRPC in a Nodejs
- A deep dive into Vite