Thursday, June 13, 2024
HomeRuby On RailsHow I migrated a Rails app from Webpack to esbuild and bought...

How I migrated a Rails app from Webpack to esbuild and bought smaller and quicker JS builds

Within the final week, I’ve been chargeable for migrating a fairly large (300k+ strains of JS code) venture from Webpack 4 to esbuild. In our Rails venture, we have been utilizing Webpacker to combine our JS stack with the principle utility. For the final months, we have been scuffling with very lengthy builds and in addition have been locked into Webpack 4 as Webpacker turned a deprecated library. When a number of Webpack 4 dependencies obtained their CVE, we determined it was time to modify to another bundler.

After brief investigation, we determined to make use of esbuild to organize our JS entries. And as time has handed and we now have lastly mentioned goodbye to IE 11, there was additionally a possibility to enhance the stack by switching to ECMAScript modules – nearly 95% of net customers can perceive them and there’s no level in not utilizing them at the moment. And few years after we checked out our JS stack for the final time, it turned out that we don’t want Babel and a few polyfills anymore as most options we’re utilizing are already inbuilt fashionable browsers.

On this put up, I’m describing and discussing the present configuration of our JavaScript setup.

esbuild configuration

Our present esbuild choices appear like this:

const glob = require("glob");
const esbuild = require("esbuild");
const {lessLoader} = require("esbuild-plugin-less");
const isProduction = ["production", "staging"].contains(
  course of.env.WEBPACK_ENV

const config = {
  entryPoints: glob.sync("app/javascript/packs/*.js"),
  bundle: true,
  assetNames: "[name]-[hash].digested",
  chunkNames: "[name]-[hash].digested",
  logLevel: "information",
  outdir: "app/property/builds",
  publicPath: `${course of.env.CDN_HOST ?? ""}/property`,
  plugins: [lessLoader({javascriptEnabled: true})],
  tsconfig: "tsconfig.json",
  format: "esm",
  splitting: true,
  inject: ["./react-shim.js"],
  mainFields: ["browser", "module", "main"],
  loader: {
    ".js": "jsx",
    ".locale.json": "file",
    ".json": "json",
    ".png": "file",
    ".jpeg": "file",
    ".jpg": "file",
    ".svg": "file",
  outline:  "improvement"),
    VERSION: JSON.stringify(course of.env.IMAGE_TAG ,
  incremental: course of.argv.contains("--watch"),
  sourcemap: true,
  minify: isProduction,
  metafile: true,
  goal: ["safari12", "ios12", "chrome92", "firefox88"],

Let’s begin from entryPoints listing – as we have been utilizing Webpacker, we had a fairly customary location for our entries (packs) – app/javascript/packs. I didn’t need to listing all entries that we have been creating (and bear in mind so as to add any new entries manually) so I used the glob package deal to generate the listing of all information matching the given sample. This manner I can add any new entries to app/javascript/packs listing and they are going to be mechanically constructed on the subsequent run. We do need to inline all imported dependencies into created entries so we’re utilizing bundle: true setting.

The subsequent two settings: assetNames and chunkNames are set to correctly deal with the asset pipeline managed by sprockets and forestall digesting chunks and property twice. Observe the -[hash].digested half – that is required so as to not digest generated information as soon as once more in sprockets. With out these settings, all dynamic imports or imports of information dealt with by the file loader gained’t work after the property compilation.

As we’re constructing multiple entry, the outdir property is required. It’s set to app/property/builds in order that we are able to combine it with the Rails asset pipeline later.

In most of tutorials I discovered, the publicPath was set to property. However this setting gained’t work correctly when splitting possibility is enabled – imported chunks could have a relative path in order that they gained’t work on most pages. This can even not work with any CDN. We’re prepending the publicPath with the CDN host set by the env variable in order that we don’t must serve property by means of the Rails app server (CDN_HOST is the same as config.asset_host worth).

We’re utilizing one plugin for LESS help – we’re going to maneuver all CSS out of the JS stack, however not all stylesheets have been moved out but and a few of them are utilizing LESS. This plugin will probably be eliminated quickly.

With Webpacker, we have been utilizing a resolve alias. It allowed us to not use relative paths in imports, we might use import foo from "~/foo" as an alternative. esbuild doesn’t help such aliases by default, nevertheless it has help for tsconfig.json information. The tsconfig possibility permits specifying the trail for the tsconfig.json file. This works additionally if you’re not utilizing TypeScript in your utility. Our tsconfig.json file seems like this:

  "compilerOptions": {
    "goal": "es6",
    "baseUrl": ".",
    "paths": {
      "~/*": [
  "embrace": [

compilerOptions.path object permits us to protect imports from Webpacker and resolve the ~/ prefix correctly.

The subsequent choices (format & splitting) allow ESM format and help for chunk splitting with dynamic imports. As we’re utilizing dynamic imports and have a number of lazy-loaded pages in our single-page utility, we would have liked this selection to optimize the scale of JS information wanted for the preliminary render. To correctly deal with React elements we would have liked a shim that’s injected into each file generated by esbuild (inject possibility). Our react-shim.js file could be very easy and appears like this:

import * as React from "react";
export {React};

We additionally wanted to vary the default mainFields setting as one or two libraries we have been utilizing weren’t exporting code for the browser appropriately. That resulted in errors throughout the construct as node packages weren’t obtainable. You may seek advice from the mainFields documentation to learn extra about this.

There may be one other story with loaders. In our venture, we’re utilizing i18next with customized backend to deal with i18n. We retailer translations in JSON information and cargo them by way of dynamic imports. To make it work with digested property we would have liked to make use of the file loader in order that we are able to get the digested URL in our i18n init module. However after utilizing simply {".json": "file"} loader, it turned out that we began getting different errors from certainly one of our dependencies. After a quick investigation, it turned out that requiring certainly one of our dependencies have a aspect impact of importing a JSON file. Consequently we couldn’t simply use the file loader for all JSON information. We ended up with utilizing .locale.json suffix for our translation information and utilizing the file loader just for .locale.json extension whereas leaving the json loader for .json suffix. We don’t use .jsx extension so we simply enabled JSX loader for all .js information and left the file loader for photographs.

With Webpack, we have been utilizing the outline plugin to inject env variables into the code. With esbuild, we don’t want any plugins to deal with that as there’s already a outline choice to deal with that.

The final choices are used principally for improvement mode or manufacturing optimization. The incremental possibility permits us to hurry up rebuilding property within the improvement atmosphere. We’re not utilizing the built-in watch setting because it didn’t work with our setup. We determined to make use of a customized file watcher that’s utilizing chokidar package deal to observe our JS listing and rebuild entries after detecting any adjustments:

/* ... */
const fs = require("fs");

const config = { /* see above */ };

if (course of.argv.contains("--watch")) {
  (async () => {
    const outcome = await esbuild.construct(config);"./app/javascript/**/*.js").on("all", async (occasion, path) => {
      if (occasion === "change") {
        console.log(`[esbuild] Rebuilding ${path}`);
        console.time("[esbuild] Carried out");
        await outcome.rebuild();
        console.timeEnd("[esbuild] Carried out");
} else {
  const outcome = await esbuild.construct(config);
  fs.writeFileSync( a part of(__dirname, "metafile.json"),

In the intervening time we’re producing the supply maps in all environments and minifying the code for manufacturing environments. The metafile is saved in metafile.json when the file watcher is just not used.

The final possibility: goal specifies the goal atmosphere for the generated JS code.

We retailer our esbuild config within the esbuild.config.js file and use the next scripts in our package deal.json file to construct/rebuild all JS information:

  "scripts": {
    "construct:js": "node esbuild.config.js",
    "watch:js": "node esbuild.config.js --watch",

Rails integration

After inspecting jsbundling-rails sources we now have determined to not use that library and simply add one rake process that’s doing the identical as that gem. Integrating esbuild with Rails could be very simple and the one factor that’s wanted is to be sure to add this line to your app/property/config/manifest.js file:

You also needs to improve a number of Rake duties to ensure your esbuild property are generated earlier than the asset precompilation. We’re utilizing this code to make it work:

namespace :javascript do
  desc "Construct your JavaScript bundle"
  process :construct do
    system "yarn set up" or increase
    system "yarn run construct:js" or increase

  desc "Take away JavaScript builds"
  process :clobber do
    rm_rf Dir["app/assets/builds/**/[^.]*.{js,}"], verbose: false


Now every time you run rails property:precompile the Yarn package deal supervisor will set up all dependencies after which construct your JS information into app/property/construct. If you wish to set the file watcher whereas growing your app, you’ll be able to simply run yarn run watch:js in your terminal and begin your dev server.

Potential enhancements

This configuration works completely for us in the meanwhile and resulted in smaller and quicker builds. With Webpack we would have liked from 6 to fifteen minutes to construct our property, relying on the load. With esbuild, we are able to construct minified property in lower than a minute. And it’s even quicker in improvement – the preliminary construct with the file watcher enabled takes lower than 5 seconds on my 2019 iMac and rebuilds are even quicker. Nonetheless, there are nonetheless some issues we’re going to enhance sooner or later. When growing your JS information regionally, if you happen to change information which might be utilized in many different modules typically, the variety of information generated in app/property/builds might develop very quick. That may make your Rails server very sluggish on the primary request. I made an esbuild cleanup plugin to resolve this problem.

One other problem is with the app/property/builds itself. Should you begin the watch:js script and there could be any error throughout the rebuild, esbuild course of will probably be killed however the property listing gained’t be cleaned so your property will nonetheless be served by Rails and also you would possibly marvel why you’ll be able to’t see adjustments in your modules after refreshing the web page. With the intention to repair this we’ll most likely want one other course of that can handle esbuild course of state and restart it or at the very least ship a notification on error. If you know the way to resolve this problem, be happy to share your data.



Please enter your comment!
Please enter your name here

Most Popular

Recent Comments