At authentik, we have six individual applications for supporting our operations. Users have their traditional "here are you accesses" list; Administrators have the AdminUI; our actual SSO handling is done with a special app named "Flow," plus we have extra apps for when you're not logged in, when you're waiting to get logged in, and when you just want to interact with the public API.

Our build tool of choice was Rollup. Rollup is faster than Webpack, but more limited. And still, with that, our modestly large (~50K lines) application repository took 50 seconds to build on a modern developer's laptop. 50 seconds is a terrible delay; it's just enough for your typical ADHD developer like myself to wander off and think about something else. Given that we're a scrappy start-up, it's also a terrible delay when demoing the product for potential customers and showing them how easy it is to make changes.

I decided it had to go faster. This is how we did it, getting our build time down from 50 seconds to less than one second.

Our Rollup configuration was pretty generic. The only unusual extensions we had were an inline converter from CSS-as-text to CSS-as-CSSImport, a similar converter from Markdown for our larger in-line docs, and the use of rollup-plugin-copy to make sure the icons, images, and CSS Custom Property files were copied into the build target folder. And all of that took 50 seconds to run.

For a while, I thought the solution was switching to a monorepo so that I could exploit monorepo tools for dependency graph monitoring and parallel compilation. I also thought I would use ESBuild for those packages that didn't need the full Rollup magic . But while I was struggling to get any monorepo tool at all to actually work the way I expected, and the way I wanted, I stumbled upon a conversation on Github that opened a whole new world to me.

ESBuild is scriptable. You can import ESBuild as a node module and command it directly from within a NodeJS program. Which means that you can use all the power of NodeJS and Javascript to organize your build before calling ESBuild and performing the actual build.

There is a bit of sacrifice to this approach. Our custom CSS-as-CSSResult rollup plugin was not available for ESBuild, and the fellow who'd written it was no longer available. But this is Lit, which has the css() function; going back to the pre-Rollup method of importing CSS as text and then converting it at runtime takes a few extra milliseconds on the client side, but that was a small price to pay for what came next.

The first thing we needed was to make sure all the assets were properly copied. That turned out to actually be fairly straightforward:

for (const [source, rawdest, strip] of otherFiles) {
    const matchedPaths = globSync(source);
    const dest = path.join("dist", rawdest);
    const copyTargets = matchedPaths.map((path) => nameCopyTarget(path, dest, strip));
    for (const [src, dest] of copyTargets) {
        if (fs.statSync(src).isFile()) {
            fs.mkdirSync(path.dirname(dest), { recursive: true });
            fs.copyFileSync(src, dest);
        }
    }
}

The input array for this is [sourcePath, targetFolder, strip]; the source path is either and individual file or a glob expression; the target folder is just that, and "strip", if it's present, is a string that tells the nameCopyTarget function to pull part of the generated target path off the glob product so that it winds up in the right place:

const nameCopyTarget = (src, dest, strip) => 
    ([src, path.join(dest, strip ? src.replace(strip, "") : path.parse(src).base)]);

I'm a huge fan of table-based development, meaning that I like to write code that writes code, and then feed it a table of the instructions that I want it to execute.

So given a new table of "Here's the entry point for the app and here's where I want you to put it..."

const theApps = [
    ["admin/AdminInterface/AdminInterface.ts", "admin"],
    ["user/UserInterface.ts", "user"],
    ["flow/FlowInterface.ts", "flow"]
];

I wrote a function that builds those:

function buildOneSource(source, dest) {
    const DIST = path.join(__dirname, "./dist", dest);
    try {
        esbuild.buildSync({
            ...baseArgs,
            entryPoints: [`./src/${source}`],
            outdir: DIST,
        });
    } catch (exc) {
        console.error(`[${new Date(Date.now()).toISOString()}] Failed to build ${source}: ${exc}`);
    }
}

The baseArgs for ESBuild aren't very special; bundling, sourcemap, minify, split, treeshake, and tsconfig are all there, just as you'd expect. The only detail is that it the .css and .md loaders are now just "text", rather than a plugin.

Running this in a loop, our build time fell from 50 seconds to barely a little over 2 seconds.

But then, while I was playing with something else, I found that a plug-in I though I wanted didn't work with ESBuild buildSync; it had to be run in an asynchronous environment. So now it was async function buildOneSource and esbuild.build(). (Note that there is no await on the ESBuild call, not even in this asynchronous version. That's important.)

Which made me wonder: what if, instead of a loop, I did:

await Promise.allSettled(theApps.map(([source, dest]) => buildOneSource(source, dest)));

Doing this means you understand a bit of the Deep Magic: async/await are just syntactical sugar for Promises, and by not putting the awaits into any of this code, NodeJS will launch separate, independent instances of the ESBuild build server all at once, then wait for them all to finish. I have an eight-core machine, and we have only six independent apps. Each one gets its own thread. The larger builds get put into the queue first so a CPU can go to work on those while the smaller ones are still being set up in the kernel's process space.

The build process is now down below 1 second.

Overengineering

At this point, you might think I was happy, but no, I wanted it to be even faster. I had a brainstorm while I was driving to an appointment: Could ESBuild tell me what files were involved in the building of any one app?1 Yes it can, using the metafile option. For every build, metafile will tell you what files were involved in the build. Which means that after the first build, you have a cache of what files were involved and, when you do your development on the UI, could detect which app(s) would be affected by your changes and build only the entry points for those applications.

And if your pre-build analyzer detected a file that wasn't in any application, it could always just rebuild all of them to add it to the cache. With a build time of less than one second for all the apps total, that wasn't a huge cost.

This turned out to be do-able, but the overhead and complexity of the build script at that point was getting silly, and we chose not to pursue this option further. It's still there, still part of our time budget if we ever need it, but for the moment we're not using it.

Watching

The final thing we lost was Rollup's watch feature. Not a big deal, but desirable for developers. Using the chokidar file monitoring library made that straightforward:

if (argsMatch(["-w", "--watch"])) {
    chokidar.watch("./src").on("all", async (event, path) => {
        if (!["add", "change", "unlink"].includes(event)) {
            return;
        }
        if (!/(\.css|\.ts|\.js)$/.test(path)) {
            return;
        }
        await Promise.allSettled(theApps.map(([source, dest]) => buildOneSource(source, dest)));
    });
}

We watch for the events, check to make sure the file changes really is one that needs building, and if so, we call build on the whole thing (since we didn't overengineer our dependency graph watcher). By the time a developer has switched to the browser the build is done and the results can be assessed.

Conclusion

Rollup (and Vite, it's descendent) are fantastic tools, but they're written in JavaScript and beginning to show their age. Rollup, after all, is what you use if you're not doing anything so complicated you need WebPack. ESBuild is what you use if you're not doing anything so complicated you need Rollup, and in this day the "evergreen" browsers tend to mean that you don't need much of the feature-based backporting Webpack and Babel provide. Despite using Lit and Patternfly, a fairly obstreperous CSS Library, we really weren't doing anything extraordinary, or that didn't have viable alternatives.

Converting from Rollup to ESBuild required a leap of faith, from a command-line tool to a fully scripted nodeJS application that used simple tools to marshal our resources, copy our assets, and build our applications. In doing so, we reduced our build time from 50 seconds to 900 milliseconds, all the while retaining and even improving on the watch feature our developers enjoy having.

The build.mjs script includes the full source code for our current build system.

There are trade-offs to this solution, the biggest being that our CSS is no longer pre-processed. This results in a slightly longer delay until Main Content Paint (about 100 milliseconds, from 600ms to 700ms) as the CSS is processed at run-time. It also makes our CSS be distributed to our apps' Web Components by-value instead of by-reference, which blows up our memory usage on Chrome quite a bit (styling the Dashboard, for example, is 23MB of RAM vs 10MB of RAM the old way), since it's duplicating strings rather than passing references to pre-existing CSSStyleSheet objects.

So far, that hasn't presented us with any problems, and the other devs on my team are absolutely delighted with the changes. I do plan, and not just because of the performance reasons listed above, addressing the CSS pre-processing issue in the near future. But for now, this is how you do it: use more common TypeScript constructs, and use the fastest tool available. ESBuild is fast.

And, maybe SWC would be even faster once it's bundling is no longer still under construction. I don't have the bandwidth right now to keep hammering at this problem, and trying out SWC would be over-engineering the way managing my own dependency graph would be.

For the time being, we're sticking with ESBuild. It works, it satisfies the needs of our team, and it's not broken.


1 I carry a tape recorder with me everywhere, one with actual physical buttons, so I can record these moments without having to fumble with a phone or look at a screen.