Recently I had some time to take a look at the performance of my pet project. One thing bothered me: the project is relatively small, but the bundle size was too big: 780 KiB
. Whereas the project had only a few dependencies: react
(~10 KiB), react-router
(~70 KiB), react-dom
(~90 KiB), dayjs
(~3 KiB), lodash
(~70 KiB). The bundle should not be that big for so few dependencies.
So I ran webpack-dev-analyzer
and found a weird thing: the sum of the libs is 3 times smaller than the final bundle size. Then I decided to take a look at @statoscope/webpack-plugin
. It was a good tool but it gave me the same result. The bundle has way too much trash.
Ok. Probably it’s just webpack wrappers over 300+ small modules. So I asked Perplexity
- what can I do with it? Turned out all the proper options were already activated. So, I made a decision - it’s time to try esbuild. It made a huge impact on my work project, so I knew it could work out.
I knew that esbuild
didn’t support a lot of stuff that babel supports. So I expected that it would take a day to migrate the codebase to esbuild
. But surprisingly once I replaced babel-loader
with esbuild-loader
the build command worked out. The build didn’t work but the bundle was assembled without any errors.
Did it help? Nope. Instead of 780 KiB
, I got ~1 MiB
. Luckily I found out that there’s also EsbuildPlugin
, which replaces TerserPlugin
and works better. Added this line, it worked like a charm, and… Whoa! 280 KiB
. That’s huuuge.
So I figured out - it worked. Now I have to find a way to fix the app (spoiler: it didn’t work at all).
Implicit React imports
React 17+ supports the possibility of avoiding adding import React from ‘react’
to every TSX-file. esbuild
supports it too, but somehow it didn’t work out of the box. Solution:
const esbuildLoader: RuleSetRule = { test: /\.(js|ts|tsx)$/, loader: 'esbuild-loader', exclude: /node_modules/, options: { target: 'es2020', minify: cfg.PROD, + // Support implicit React imports: + jsx: 'automatic', + jsxFactory: 'React.createElement', + jsxImportSource: 'react', } satisfies LoaderOptions, };
TSX-Control-Statements
I just removed the packages and all their <If/>
s. I liked them though. There’s a plugin for Vite
that adds their support. But I still use Webpack
and the plugin works via string replacements, not via AST.
SourceMaps
To enable sourcemaps I had to disable EsbuildPlugin
for development. Theoretically, it supports sourcemaps, but no matter what I did it didn’t work. I even debugged the package itself. Didn’t help. So I surrendered and enabled it only for the prod build.
TypeScript
EsBuild supports TypeScript syntax out of the box but it doesn’t check types. So it builds extremely fast but I need to know my errors. Solution:
webpackConfig.plugins!.push( new ForkTsCheckerWebpackPlugin({ typescript: { configFile: cfg.paths.TS_CONFIG, }, }) );
How does it work? The build and the type-checking processes are run in parallel. The 2nd doesn’t linger the 1st. You still can see the errors in the browser (and CLI) console. And visually too. Nice.
ES-Lint
For some reason, it stopped working. The solution was pretty simple:
webpackConfig.plugins!.push( new ESLintPlugin({ extensions: ['ts', 'tsx'],
Final results
- File sizes
- Before: 641 KiB + 234 KiB + 29.5 KiB (css) =
904.5 KiB
- After: 221 KiB + 55 KiB + 8.1 KiB (o_O, css) =
284.1 KiB
- After
minifyIdentifiers: true
(scary thing) &minifyWhitespace: true
: 138 KiB + 44.2 KiB + 8.1 KiB =190.1 KiB
- Before: 641 KiB + 234 KiB + 29.5 KiB (css) =
- DOMContentLoaded:
- Before:
737ms
- After:
231ms
- After-2:
131ms
- Before:
- Build time:
esbuild
was ~3x faster
Afterthought
Taking a deeper look I found:
- The main issue wasn’t in
babel
. The main issue was that I usedinline-source-map
for prod. I didn’t want to do it. Just a mistake. And both bundle analyzers didn’t notice that. Even when I manually looked at the file I barely found it (the horisontal scroll was too big). Fixing this issue dropped the file size 3x times even withbabel
. esbuild
has a better wrapper logic for a ton of tiny files thanbabel
does.- Whitespace minification is quite important.
GZip
doesn’t eliminate the difference. - Name mangling is a beast. But be careful, it can easily break your app in runtime.