Lessons learned switching to Rspack

Web apps

NOTE: This post was published while Rspack’s latest version was v1.0.0-beta.4. Some details are likely to change as Rspack approaches its 1.0 release.

Rspack is a Rust-based alternative to Webpack that promises to be faster and includes a few common conveniences too. After a year of trying, I’ve finally finished converting my two largest Webpack projects to Rspack. Here are some of the things I learnt along the way.

Why Rspack?

First, however, why choose Rspack? For me it’s because I’m already using Webpack for my two largest projects and Rspack offers a comparatively simple and low-risk upgrade compared to other build tools. The build times for these projects are also slow enough that speeding them up could make a worthwhile difference to productivity and CI costs.

But while we’re switching build tools, why not switch to Vite? A lot of people have switched from Webpack to Vite and seem to love it. The reasons I decided to go with Rspack for these particular projects are:

  • Coming from Webpack, it’s a much simpler and less risky upgrade path.
  • From what I can tell, Vite’s on-demand file serving appears to shine in SSR contexts like Next.js apps but I’m using Webpack for an SPA and a Web extension where this matters less.
  • My experience with Vite so far hasn’t been great.
  • I like to keep dev and prod builds as close as possible so I can confidently test what I’m shipping. Vite is weak in this area with its esbuild-based dev mode and Rollup-based prod builds. In fact, I found the gap so large that I gave up implementing some features in dev mode with Vite because it was too much effort to implement them twice.
  • Rspack is apparently faster than Vite, at least in the areas that matter to me.

Perhaps the biggest problem with Rspack is that, like Webpack, it’s not easy to get started with and that appears to be why the Rspack team have also produced Rsbuild as a more approachable layer on top of Rspack.

Lessons learned along the way

The steps involved in migrating from Webpack are covered in Rspack’s Webpack migration guide so what follows are the things I learned that weren’t covered there or that might not be obvious.

TypeScript

Rspack uses SWC to transpile TypeScript which means you no longer need ts-loader. However, after building with Rspack, I noticed that the generated JS assets were a lot bigger than when using Webpack and ts-loader.

It turned out that a lot of boilerplate was being generated for features like async iterators. In the Webpack build ts-loader was picking up my tsconfig.json which specified "target": "es2020" so these features were not being downleveled. In Rspack, however, SWC was downleveling them to ECMAScript 5. The solution was to specify the target for SWC in the Rspack config.

For example:

const config = {
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: {
          loader: 'builtin:swc-loader',
          /** @type {import('@rspack/core').SwcLoaderOptions} */
          options: {
            sourceMap: true,
            jsc: {
              parser: {
                syntax: 'typescript',
              },
              target: 'es2022',
            },
          },
        },
        type: 'javascript/auto',
      },
      // ... similarly for tsx files
    ],
  },
  // ...
};

I believe it’s also possible to achieve this by specifying browser versions using the env.targets setting.

Even after doing that, however, the JS assets generated by Rspack were still larger but when 1.0 alpha enabled optimization.concatenateModules by default they fell to within 3% of their Webpack counterparts and were sometimes fractionally smaller.

Type checking

One consequence of using SWC to transpile TypeScript is that type checking is no longer performed. You’ll need to enable isolatedModules in your tsconfig.json and work out when and how you want to perform type checking.

I opted to use fork-ts-checker-webpack-plugin as recommended by the official docs but on reflection, I wonder if that’s even necessary. Assuming you have your editor set up to perform type checking and you run tsc in CI, and perhaps as a pre-commit hook too (e.g. using tsc-files), then you might not even need to perform type checking on each build.

Using Webpack I was ignoring certain TypeScript errors during development so that they didn’t interrupt me while refactoring like so:

use: {
  loader: 'ts-loader',
  options: env.ignoreUnused
    ? {
        ignoreDiagnostics: [
          6133 /* <variable> is declared but its value is never read */,
          6192 /* All imports in import declaration are unused */,
        ],
      }
    : undefined,
}

With Rspack, if you’re performing type checking using fork-ts-checker-webpack-plugin, you can pass in similar options there instead:

new ForkTsCheckerWebpackPlugin({
  issue: {
    exclude: env.ignoreUnused
      ? [
          // <variable> is declared but its value is never read
          { code: 'TS6133' },
          // All imports in import declaration are unused
          { code: 'TS6192' },
        ]
      : [],
  },
}),

CSS

Although the migration guide mentions replacing mini-css-extract-plugin with rspack.CssExtractRspackPlugin, the documentation for CssExtractRspackPlugin states:

If your project does not depend on css-loader, it is recommended to use the built-in CSS solution experiments.css of Rspack for better performance.

As a result, for my Web app project, I was able to update my CSS configuration from:

// rspack.config.js
const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: MiniCssExtractPlugin.loader },
          {
            loader: 'css-loader',
            options: {
              url: false,
              importLoaders: 1,
            },
          },
          {
            loader: 'postcss-loader',
          },
        ],
      },
      // ...
    ],
  },
  // ...
};

To just:

const config = {
  experiments: {
    css: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [{ loader: 'postcss-loader' }],
        type: 'css/auto',
      },
      // ...
    ],
  },
  // ...
};

However, I noticed my CSS assets were ~11% larger than those generated from Webpack. Comparing the output, I discovered that the Rspack output was not performing some optimizations like converting rgba() colors to hex values.

By default Rspack uses Lightning CSS to minimize CSS assets and it turns out the features Lightning CSS uses as part of its default settings are very conservative. After adding some more recent values to the minimizerOptions.targets property Rspack’s output was slightly smaller than Webpack’s:

  optimization: {
    // ...
    minimizer: [
      new rspack.SwcJsMinimizerRspackPlugin({
        // ...
      }),
      new rspack.LightningCssMinimizerRspackPlugin({
        minimizerOptions: {
          targets: [
            'last 2 Chrome versions',
            'Firefox ESR',
            'last 2 Safari versions',
          ],
        },
      }),
    ],
  },

Update (2024-08-17): It looks like the Rspack team have taken note of this and added default targets for Lightning CSS in beta 5!

Service workers

My app includes a Service Worker and relies on the InjectManifest plugin from Google’s Workbox to trigger the generation of the Service Worker asset and populate it with a precache manifest. Unfortunately when I went to migrate my app, this plugin wasn’t supported by Rspack.

Rspack 1.0.0-alpha.0, however, added native support for recognizing Service Workers (and various kinds of worklets too). It’s a little bit quirky in that you need to pass a URL object to navigator.serviceWorker.register() (not a string) and you can’t pass a variable referring to a URL object either.

For example, the following would cause a chunk to be generated for the service worker at sw.ts:

const registrationPromise = navigator.serviceWorker.register(
  new URL(
    /* webpackChunkName: "serviceworker" */
    './sw.ts',
    import.meta.url
  )
);

You also need to take care to ensure the service worker chunk ends up with a fixed filename rather than one with a cache-busting hash in it:

// rspack.config.js
const config = {
  // ...
  output: {
    chunkFilename: (assetInfo) => {
      if (assetInfo.chunk?.name === 'serviceworker') {
        return '[name].js';
      }
      return '[name].[contenthash].js';
    },
  },
};

However, all that still won’t help with embedding a precache asset manifest like InjectManifest does. I tried various alternatives but none of them seemed to work with Rspack’s native service worker support so I ended up writing my own by extracting just the needed parts of Workbox’s InjectManifest plugin and adapting them to Rspack.

Since then, Rspack have announced that Workbox is fully supported. My package is smaller and seems to work better than Workbox when making changes so I’ll stick with it for now but I’m sure most people will be happy to continue using Workbox as-is.

One quirk of the native Service Worker support, however, is that it appears to run before dead code elimination. As a result, if you are using build-time constants to disable service worker registration like in the following code, you may find the service worker asset is still generated even though it’s not referenced anywhere.

function registerServiceWorker() {
  if (!('serviceWorker' in navigator) || !__ENABLE_SW__) {
    return Promise.resolve(null);
  }

  // The following code will be eliminated when __ENABLE_SW__
  // is falsy, but the sw.js asset will still be generated.
  const registrationPromise = navigator.serviceWorker.register(
    new URL(
      /* webpackChunkName: "sw" */
      './sw.ts',
      import.meta.url
    )
  );

  // ...

  return registrationPromise;
}

React Cosmos

Perhaps the biggest hurdle in migrating to Rspack was getting React Cosmos to play with it. I’m a big fan of React Cosmos for developing and testing components because I found that unlike Storybook it doesn’t require a lot of additional development dependencies but works with your existing bundler instead.

Unfortunately React Cosmos does not include support for Rspack, only Webpack, Vite, and a few others. It includes instructions on how to configure a custom bundler but I figured, “How hard can it be to port the Webpack plugin to Rspack?”

The answer, it turned out, was “quite hard” mostly due to me insisting on building the plugin with tsup and getting confused about how each endpoint should be bundled. A few weeks later, however, react-cosmos-plugin-rspack was born offering a drop-in replacement for the Webpack plugin.

Knip

The final dependency requiring attention was Knip. Knip is a tool for detecting unused cruft—dependencies, exports, files, etc.—in your project. If it sees you are using Webpack, it will look up your Webpack config and detect your project’s entrypoints, Webpack plugins etc. and analyze your project accordingly. Unfortunately, it didn’t know about Rspack.

My company is a sponsor of Lars’ work on Knip so I reached out to see if we could commission a Knip plugin for Rspack. Lars jumped on it and invited others to join in the crowdfunding and a few days later it was complete.

As a result, Knip detected that I no longer needed any of the following dependencies:

Command-line flags

Rspack CLI includes a built-in dev server but unfortunately, the command-line flags differ from webpack-dev-server in a few places, notably missing options like --port and --hot/--no-hot.

To handle such cases I ended up using various --env values and checking them in rspack.config.js’s configuration function. This seems like a potentially unnecessary point of friction that could be eased in a future release.

Update (2024-09-05): This has been fixed in version 1.0.1!

Rsdoctor

The Rspack team have helpfully created a plugin, Rsdoctor for analysing your bundle and its build times. For what it’s worth, I ended up with the following configuration:

new RsdoctorRspackPlugin({
  disableTOSUpload: true,
  linter: {
    rules: {
      // Don't warn about using non ES5 features
      'ecma-version-check': 'off',
    },
  },
  supports: { generateTileGraph: true },
});

What didn’t change

Apart from the above changes, everything else appeared to work as-is including the following plugins:

I also use the Bugsnag Webpack plugins but they only run on a new release so I haven’t had a chance to test them yet.

Results

So how much faster is Rspack after all?

For my SPA project I saw the following results:

EnvironmentWebpackRspackRspack without type checking
Home laptop19.1s8.1s (-57.7%)3.04s (-84.16%)
Work desktop13.24s5.57s (-57.94%)2.44s (-81.61%)

Since type checking is performed in a separate process, most of the time you’re only waiting on the build and the final column is what matters most.

Furthermore, if I am reading the results of Rsdoctor correctly, it seems that most of the compile time comes from postcss-loader so I hope that when Tailwind 4 is released I can switch to using builtin:lightningcss-loader instead and see further improvements to the compile time.

For my Web extension project, the improvements were even more significant, going from 11.65s to 3.6s (69% faster) on my desktop computer even with type checking enabled.

Update (2024-09-05): The Rspack team have added a list of performance bottlenecks. In addition to postcss-loader I am also using html-webpack-plugin from that list. I currently need that in order to use html-webpack-inject-preload (since it checks for the HtmlWebpackPlugin constructor name). If I can migrate that to work with HtmlRspackPlugin it might improve the build time further.

Looking forward

As Rspack is approaching 1.0, we’ve had announcements for two more Rust-based build tools (which also appear to have roots in China) in Farm and Mako. Obviously there is also Turbopack, the Rust-based successor to Webpack, and Rolldown, the Rust-based version of Rollup.

It will be interesting to see which of these tools gets traction. Personally I think Rspack has a good chance of succeeding because:

  1. Webpack is probably the most widely used build tool for apps today and Rspack provides the simplest and lowest risk migration path from Webpack. It’s easy to overlook this point but hopefully this post demonstrates how even for a modestly complex app like mine, migrating to a different build tool, even one that is largely compatible, can be quite involved.

  2. Webpack has a rich plugin ecosystem, most of which work in Rspack. Many of the services I use like Relative CI and Bugsnag provide a Webpack plugin but don’t support any other build tools. Other build tools will be at an initial disadvantage unless they offer compatibility with Webpack’s plugin API.

  3. Rspack is fast enough. Other tools might end up being fractionally faster (curiously both Mako and Farm claim to be marginally faster than Rsbuild but don’t show how they compare with Rspack) but if your build is already only 2~3 seconds, even an order of magnitude faster is not going to be a compelling enough reason to switch.

As Rspack approaches 1.0 I’m looking forward to seeing how it’s received and where the team takes it from here.

Leave a reply

Most markdown like **bold** and _italic_ is supported.

Never shown. Only used for looking up your gravatar.

Optional
https://