Speeding up our Angular app with esbuild
At Cardiologs we are building applications that allows health professionals to save time on Electrocardiogram (ECG) analysis thanks to our AI.
We develop a SaaS offering that is built on the following tech stack:
Recently we have created a new product using React and Vite, and we were blown away by how much Vite improves the developer experience with faster build times.
Our Angular app is pretty big with 259 components, 47 modules, 800k lines of code. It feels slow to start compared to our React stack, and it’s something we’ve wanted to improve for a while.
Since v17 the Angular team added support for esbuild: https://angular.io/guide/esbuild
Let’s try to make the switch from Webpack to esbuild, and see what it changes for us performance wise.
What had to be changed
We’re using Nx and enabling esbuild starts by changing the build executor in project.json:
"build": {
- "executor": "@angular-builders/custom-webpack:browser",
+ "executor": "@angular-devkit/build-angular:browser-esbuild",
}This is going to break a few things, here’s what we had to change:
ESM default import
We import a few CommonJS modules that do not support ES6 module syntax, for example momentjs:
▲ [WARNING] Calling "moment" will crash at run-time because it's an import namespace object, not a function [call-import-namespace]
Consider changing "moment" to a default import instead:
libs/help/src/lib/help/manual/manual.component.ts:5:7:
5 │ import * as moment from 'moment';
│ ~~~~~~~~~~~
╵ momentThis error can be fixed by adding this flag in tsconfig.json:
{
"compilerOptions": {
+ "esModuleInterop": true,
}
}And then altering all the default imports into something like this:
- import * as moment from 'moment';
+ import moment from 'moment';We’re also phasing out moment in favour of date-fns in our codebase to address the bundle size issues in the long run.
See https://angular.io/guide/esbuild#esm-default-imports-vs-namespace-imports
Node environment variables with Webpack
Our Webpack build was using the @angular-builders/custom-webpack builder in order to retrieve Environment variables at build time, and forward them to the browser:
function getClientEnvironment() {
// Grab NX_* environment variables and prepare them to be injected
// into the application via DefinePlugin in webpack configuration.
return {
'process.env': Object.keys(process.env)
.reduce((env, key) => {
if (/^NX_/i.test(key)) {
env[key] = JSON.stringify(process.env[key]);
}
return env;
}, {});
};
}Then during the build we would pass the variables: NX_TAG=pr-123 npx nx build. The custom webpack config above would then allow the env variables to be available in the browser as process.env like in a node application.
With esbuild we do not have a straightforward way to get those env variables anymore. As a result, instead of trying to inject process.env into the browser we simply write a JSON file at build time during our Github Actions workflow:
- name: Pass the build tag into the app without using process.env
run: printf '{"tag":"%s"}\n' "${{ inputs.tag }}" > env.json+ import env from './env.json';
export default {
APP_ENV: getEnv(),
- APP_VERSION: process.env.NX_TAG ?? 'dev',
+ APP_VERSION: env.tag,
};Relative SCSS imports
Many SCSS @importstopped working:
✘ [ERROR] Can't find stylesheet to import.
╷
1 │ @import 'libs/heartbeat/src/lib/assets/scss/hb-foundations';
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
╵These are relative imports from one Nx lib to another, the libs folder is at the root of the repository.
One solution is to use a correct relative path, but that requires updating many files and makes the code arguably uglier.
+ @import '../../../../heartbeat/src/lib/assets/scss/hb-foundations';
- @import 'libs/heartbeat/src/lib/assets/scss/hb-foundations';Another solution is to add the root of the repository as a path can be used for SCSS imports, using project.json:
"build": {
"executor": "@angular-devkit/build-angular:browser-esbuild",
"options": {
"stylePreprocessorOptions": {
"includePaths": [
"node_modules",
+ ""
]
}Performance benchmark
Now that we’re up and running let’s check how build performance is impacted:
Our build takes quite a while on Github actions, saving 47 seconds here is nice but it’s not going to change much. Most of the time spent in our CI is E2E tests anyway 😅
Here we’re starting to see some real nice gains. Waiting 1 minute in front of your computer for nx build to complete is very painful.
Most people don’t actually build but want to serve so let's see how the dev server improves.
The dev server here is using Vite behind the scenes, even though it’s not really exposing its configuration or internals.
The gains here are really noticeable. Saving more than 20 seconds on the startup time of nx serve will be a nice quality of life improvements for all the devs on the project.
During development we’re going to start the dev server once and then edit the source code tens of times, causing the dev server to rebuild our code.
Here nothing changes with Vite:
- we spend about 1 second rebuilding the code when a piece of code changes
- the dev server then sends a full page reload to the browser. It may take a while to reload depending on the application. Most of the time is spent there.
Conclusion
It was pretty easy to update our app to use the new builder, and we can quickly have some nice gains on build time and initial startup time for nx serve.
If you have an Angular application and haven’t made the switch to esbuild yet, you’re missing out. It’s great to see such improvements coming to Angular! 🚀
However we’re still missing something when comparing to what Vite offers: on a React+Vite dev server, changing some code does not trigger a full page reload and is instantly reflected inside the browser. Angular still needs to reload the entire page.
Hopefully we can see some new additions in Angular+Vite like Hot Module Replacement (HMR) in the future? 🤔
Cardiologs is hiring!
If you want to join us in our Paris office and build awesome frontend products, check out our job offers: https://jobs.lever.co/cardiologs/
