SHARE
In the Ruby on Rails world, Webpack was the standard for a long time, thanks to webpacker. But for large projects, it became a major bottleneck. In this article, we look at a real case of migrating a Rails + React + SCSS project to esbuild, which reduced build time from 25 minutes to 10.
The first time I worked with migration to esbuild was back in 2023. At that time, Webpack builds were becoming painfully slow and started affecting our delivery speed. We needed a faster solution.
At first, we tried Vite. It looked like a great modern alternative, but we had one serious problem: the project relied heavily on GraphQL file imports and GraphQL fragments in .graphql files. At that time, Vite did not support .graphql files properly without unstable third-party plugins. I spent a lot of time trying different workarounds, but nothing worked reliably.
Since esbuild had a plugin that supported GraphQL imports almost out of the box, we switched to it. It worked immediately, and the performance difference was huge.
Later, when I joined another project, I faced the same problem again—but this time it was much more serious. Our project used Ruby on Rails with React (JSX), SCSS, and GitHub Actions for CI. Before the migration, our CI pipeline had two separate steps:
bundle exec rake assets:precompile
bundle exec rails webpacker:compile
The first step compiled Ruby and Sprockets assets. The second one compiled JavaScript and CSS through Webpack. The problem was that webpacker:compile alone took around 8–9 minutes. The full CI pipeline usually took 22–25 minutes. Deployments took too long, tests before deployment took too long, and the whole development process became frustrating.
There was also a business problem. We used GitHub Actions with a free organization account, which gave us only 3000 CI minutes per month. Because every pipeline was so heavy, we were spending all our CI minutes in just two or three weeks. So, that was the moment when we needed to move away from Webpack and not spend money on extra minutes.
Webpack works, but inside Rails, it often becomes much more complicated than it should be. In our project, Webpack came with:
@babel/core, @babel/preset-env, @babel/preset-react, etc.)babel.config.js with environment branching logicconfig/webpacker.yml configuration fileconfig/webpack/{development,production,test}.js entrypointsreact_on_rails gem as the glue between Rails and the JS bundlewebpack-dev-server for HMR in developmentAll of this existed just to bundle JavaScript and CSS, and that is too much complexity for one simple task. Esbuild replaces all of that with one fast Go binary (without Babel, huge config files, heavy plugin chains.
I wanted a completely clean start, then I added esbuild and started fixing everything that broke, so I deleted everything related to Webpack. That included:
config/webpack/development.js
config/webpack/environment.js
config/webpack/production.js
config/webpack/staging.js
config/webpack/test.js
config/webpacker.yml
config/initializers/react_on_rails.rb
babel.config.js
postcss.config.js
.eslintignore
bin/webpack
bin/webpack-dev-server
Procfile.dev
Procfile.dev-hmr
Gems removed from Gemfile:
# Before
gem 'webpacker'
gem 'react_on_rails'
npm packages removed from package.json:
"@rails/webpacker": "^5.4.0",
"@babel/core": "^7.18.10",
"@babel/plugin-proposal-private-methods",
"@babel/plugin-proposal-private-property-in-object",
"@babel/plugin-transform-runtime",
"@babel/preset-env",
"@babel/preset-react",
"babel-loader",
"babel-plugin-transform-react-remove-prop-types",
"webpack-dev-server"
CI change:
# Before
- run: |
bundle exec rake assets:precompile
bundle exec rails webpacker:compile
# After
- run: yarn build
We installed:
json
"esbuild": "^0.23.1",
"esbuild-plugin-alias-path": "^2.0.2",
"esbuild-plugin-copy": "^2.1.1",
"esbuild-sass-plugin": "^3.3.1",
"dotenv": "..."
And added simple scripts to package.json:
"scripts": {
"build": "NODE_ENV=production node esbuild.config.mjs",
"dev": "NODE_ENV=development node esbuild.config.mjs --watch --serve"
}
It became much simpler and much easier to maintain. Instead of many Webpack files, everything now lives inside one file: esbuild.config.mjs
Esbuild is intentionally simple: it does not try to guess where your files are or how they should be processed and compiled. Instead, you define everything explicitly in
how to handle each type of file (.jsx, .js, images, fonts, SCSS, icons, etc.). In return, you get full control over your setup, a clean single configuration file, and extremely fast asset compilation. A full config example is shown below.
We also needed browser caching. Esbuild supports hashed filenames like this:
public/assets/vendor--JMBCZRRQ.css
public/assets/application--A3KF92PX.js
public/assets/pages--XC71MQBN.js
This is perfect for production because browsers can cache assets for a long time, but unlike Webpack, esbuild does not generate a manifest.json. That means Rails does not know which hashed filename is the current one.
I had to write a small custom Rails helper:
# app/helpers/application_helper.rb
# ESBuild does not support hashed assets names out of the box
# so this is a workaround to simulate this behavior
# <https://github.com/evanw/esbuild/issues/1995#issuecomment-1031464855>
def hashed_asset_path(asset_source, extension = 'js')
extension = ".#{extension}"
path = Dir[Rails.public_path.join('assets/', "#{asset_source}--*#{extension}")]
hash_part = path.first&.split('/')&.last&.split('--')&.last
return nil if hash_part.blank?
hash = hash_part.sub(/#{Regexp.escape(extension)}$/, '')
"/assets/#{asset_source}--#{hash}"
end
This helper scans the public/assets folder, finds the correct file, and returns the right path for Rails templates.
Here is an example of how to use in Slim layout templates:
/ admin.slim, application.slim, etc.
= stylesheet_link_tag hashed_asset_path('vendor', 'css'), media: 'all'
= stylesheet_link_tag hashed_asset_path('application', 'css'), media: 'all'
= javascript_include_tag hashed_asset_path('pages/index'), defer: true
Which renders to:
<link rel="stylesheet" href="/assets/vendor--JMBCZRRQ.css" media="all"> <script src="/assets/pages/index--A3KF92PX.js" defer="defer"></script>
Why this works for production: assets are compiled once before the app boots, so the files are already on disk when Rails starts serving requests. The glob scan happens per-request, but is cheap, it hits the local filesystem.
Migrating SCSS Modules is the biggest source of manual work in the migration.
Webpack (with css-loader + style-loader) supports CSS Modules natively:
// BEFORE — Webpack + CSS Modules
import styles from './Button.module.scss'
const classes = cx(styles.button, {
[styles.fullwidth]: fullwidth,
[styles.success]: color === Colors.SUCCESS,
[styles.danger]: color === Colors.DANGER,
})
At build time, styles.button becomes a hashed class like Button_button__3Fq2a. This gives you locally scoped styles — no naming collisions between components.
Esbuild can compile SCSS and even handle CSS Modules (local-css type), but it does not generate a JavaScript object of { className: hashedValue } that you can import and use in JSX.
The esbuild-sass-plugin processes .module.scss files, but the output is scoped CSS — you cannot import styles from './Button.module.scss' and expect styles.button to be a string.
Rename .module.scss → .scss and change every CSS class to use a proper naming convention (BEM or component-prefixed), then reference them as plain strings in JSX:
// AFTER — esbuild + plain SCSS
// No import of styles object!
const classes = cx('uikit-button', className, {
'uikit-button__outline': outline,
'uikit-button__text': text,
'uikit-button__link': !!link,
'uikit-button-success': color === Colors.SUCCESS,
'uikit-button-danger': color === Colors.DANGER,
'uikit-button-dark': color === Colors.DARK,
})
// Button.scss (was Button.module.scss)
.uikit-button {
// ...
&-success { background: $success-color; }
&-danger { background: $danger-color; }
&__outline { background: transparent; }
}
In this project, we renamed/refactored dozens of .module.scss files across the entire app/javascript/ui-kit/ directory:
Button.module.scss → Button.scss Tabs.module.scss → Tabs.scss Tooltip.module.scss → Tooltip.scss TimeLine.module.scss → TimeLine.scss Heading.module.scss → Heading.scss Text.module.scss → Text.scss Table.module.scss → Table.scss atomic.module.scss → atomic.scss ...
Key rule: Every class that was styles.someClass must become a unique, predictable string. Use component-based prefixes to avoid collisions.
As a bonus, we also moved Storybook away from Webpack.
Before:
@storybook/builder-webpack4
After:
framework: '@storybook/react-vite'
This also made Storybook much faster to start.
the core of our esbuild setup — a single configuration file that replaces multiple Webpack configs and controls how every asset in the project is built, resolved, and optimized.
Below is a line-by-line breakdown of how it works and why each part exists.
import * as esbuild from 'esbuild'
import { exec } from 'child_process'
import { glob } from 'glob'
import { aliasPath } from 'esbuild-plugin-alias-path'
import { sassPlugin } from 'esbuild-sass-plugin'
import { copy } from 'esbuild-plugin-copy'
import path from 'path'
import dotenv from 'dotenv'
Standard imports. glob is used to scan the filesystem for alias generation. dotenv loads .env variables.
dotenv.config()
const __dirname = path.resolve()
const isProduction = process.env.NODE_ENV === 'production'
Load .env, define the working directory (ESM modules don't have __dirname), and detect environment.
const fontsOnResolvePlugin = {
name: 'fonts resolve',
setup(build) {
build.onResolve({ filter: /^\\\\.\\\\.\\\\/webfonts|fonts\\\\// }, (args) => {
return { path: args.path, external: true }
})
},
}
When SCSS files (e.g. from Font Awesome) try to import font files like ../webfonts/fa-solid-900.woff2, Esbuild would try to resolve and copy them. We mark them as external — meaning: don't process them, leave the url() reference as-is. The fonts are separately copied by the copy plugin.
These three functions dynamically build import alias maps by scanning the filesystem:
const generateComponentsAliases = () => {
const paths = glob.sync('app/javascript/components/**/*.jsx')
let aliasPaths = {}
paths.forEach((fullPath) => {
const key = fullPath.replace('app/javascript/', '').replace('.jsx', '')
aliasPaths[key] = path.resolve(__dirname, fullPath)
})
return aliasPaths
}
Why: The project uses short imports like import Button from 'components/button/Button'. Without aliases, esbuild can't resolve these. Webpack had resolve.alias in its config. Here, we generate aliases programmatically by scanning all .jsx files under components/.
const generateIndexAliases = () => { /* scans **/index.js */ }
const generateCommonConstantsAliases = () => { /* scans common/constants/**/*.js */ }
The same pattern allows import { MY_CONST } from 'common/constants/myFile'.
const generateImagesAliases = (folder) => {
// scans .jpg, .png, .ico, .svg under app/assets/images
// creates aliases like: 'images/logo.png' → '/abs/path/app/assets/images/logo.png'
}
Allows JS code to import images by short path.
const copyImages = () => {
const imagesPaths = [
'./app/javascript/ui-kit/assets/images/**/*',
'./app/javascript/ui-kit/components/avatar/icons/**/*',
'./app/assets/images/encoded/**/*',
'./app/assets/images/icons/**/*',
'./app/assets/images/jquery-ui/**/*',
'./app/assets/images/**/*',
]
return imagesPaths.map(imagesPath => copy({
resolveFrom: 'cwd',
assets: { from: imagesPath, to: ['./public/media/', './tmp/tmp-assets'] },
watch: true,
}))
}
Esbuild doesn't copy static assets automatically. The esbuild-plugin-copy plugin handles this. Images land in public/media/ (served by Rails) and also in tmp/tmp-assets for manifest generation.
watch: true means the copy also runs in watch mode (development), so adding a new image doesn't require restarting the build.
The same pattern applies to workers and fonts:
const copyWorkers = () => { /* copies ./app/javascript/workers/**/* → public/workers/ */ }
copy({ assets: { from: './app/assets/fonts/**/*', to: './public/fonts/' } })
copy({ assets: { from: './node_modules/font-awesome/fonts/*', to: './public/fonts/' } })
entryPoints: [
'app/assets/javascripts/application.js', // legacy jQuery/admin JS
'app/javascript/pages/index.js', // React app entry
'app/assets/stylesheets/vendor.scss', // third-party CSS
'app/assets/stylesheets/admin.scss', // admin panel styles
'app/assets/stylesheets/application.scss', // main app styles
'app/assets/stylesheets/mailer.scss', // email styles
],
Esbuild supports multiple entry points natively. Each produces its own output file. Webpack required separate entries or MiniCssExtractPlugin for CSS, here it's built-in.
entryNames: '[name]--[hash]', // output: application--ABC123.js (cache busting)
bundle: true, // follow and bundle all imports
platform: 'browser', // target environment (not Node)
sourcemap: true, // generate .map files for debugging
outdir: 'public/assets', // output directory
jsx: 'automatic', // use React 17+ new JSX transform (no need to import React)
logLevel: 'info',
define: {
'process.env.PROTOCOL': JSON.stringify(process.env.PROTOCOL),
'process.env.API_DOMAIN_NAME': JSON.stringify(process.env.API_DOMAIN_NAME),
'process.env.AWS_CLOUDFRONT_DOMAIN': JSON.stringify(process.env.AWS_CLOUDFRONT_DOMAIN),
},
esbuild's define does compile-time text substitution. Each occurrence of process.env.API_DOMAIN_NAME in source code gets replaced with the actual string value at build time.
sassPlugin({
filter: /\\\\.module\\\\.scss$/, // only *.module.scss files
type: 'local-css', // scoped CSS (CSS Modules mode)
cssImports: true, // allow @import in SCSS
quietDeps: true,
silenceDeprecations: ['import'],
}),
sassPlugin({
// all other .scss files
type: 'css', // global CSS
cssImports: true,
quietDeps: true,
silenceDeprecations: ['import'],
}),
Two SCSS plugin instances with different filter rules — one for CSS Modules (.module.scss), one for global styles (.scss). Order matters: the more specific filter must come first.
silenceDeprecations: ['import'] — suppresses Sass warnings about @import being deprecated in favor of @use (a large-scale SCSS refactor can come later).
loader: {
'.js': 'jsx', // treat all .js files as JSX (safe for React codebases)
'.jsx': 'jsx',
'.css': 'css',
'.scss': 'local-css',
'.module.scss': 'local-css',
'.ttf': 'file', // fonts → emit as files, return URL
'.otf': 'file',
'.svg': 'file',
'.eot': 'file',
'.woff': 'file',
'.woff2': 'file',
'.jpg': 'file',
'.png': 'file',
},
'file' loader emits the asset to the output directory and returns its URL as a string — similar to file-loader in webpack.
assetNames: '../../../../media/[name]',
esbuild by default places assets relative to outdir. This relative path redirects font/image files processed by file loader to public/media/ (navigating up from public/assets/). A bit hacky, but it works reliably.
let builds = {
production: { ...buildOptions, minify: true },
development: { ...buildOptions },
}
if (isProduction) {
await esbuild.build(builds['production'])
} else {
await executeCommand(`rm -rf public/assets`) // clear stale output
const ctx = await esbuild.context(builds['development'])
if (process.argv.includes('--watch')) await ctx.watch()
if (process.argv.includes('--serve')) await ctx.serve()
if (process.argv.includes('--dispose')) await ctx.dispose()
}
esbuild.build() call, minification enabledesbuild.context() creates a long-running context that supports watch (rebuild on file change) and serve (built-in dev server)-dispose is used in CI-like scenarios: build once, then exit cleanlyThe difference in performance between Webpack and esbuild is not just noticeable — it completely changed our CI experience.
rails webpacker:compile) → ~8–9 minutesyarn build) → ~8 secondsOverall CI pipeline:
GitHub Actions screenshots confirm these timings per step.
👉 This is roughly 60× faster asset compilation and about 2× faster total CI pipeline.
There are a few core reasons behind this performance improvement:
babel-loader → css-loader → style-loader → MiniCssExtractPluginEsbuild uses tree shaking by default, so unused code is automatically removed.
With minify: true in production:
To make the decision more grounded, we compared esbuild with other popular bundlers. Each tool solves a slightly different problem, so the choice depends heavily on project context.
| Tool | Core language | First release | Primary use case |
| Webpack | JavaScript/Node | 2012 | Maximum flexibility, complex bundling setups |
| esbuild | Go | 2020 | Extreme build speed, lightweight bundling |
| Vite | JavaScript (uses esbuild + Rollup) | 2020 | Modern dev experience with HMR |
| Parcel | JavaScript/Rust (SWC) | 2017 | Zero-config bundler |
| Rollup | JavaScript | 2015 | Library bundling, tree-shaking focused |
In practice, esbuild became the benchmark for build performance.
| Tool | Relative build time |
| esbuild | 1× (baseline) |
| Vite (production build, Rollup) | ~10–20× slower |
| Parcel 2 | ~5–15× slower |
| Webpack 5 | ~30–80× slower |
| Webpack 4 + Babel | ~50–100× slower |
Source: esbuild benchmarks (evanw.github.io/esbuild), Vite docs, community benchmarks
One important detail: Vite uses esbuild only in development for fast transforms and dependency pre-bundling. In production, it switches to Rollup, which is significantly slower. This trade-off is part of Vite’s architecture.
| Feature | Webpack 5 | esbuild | Vite | Parcel 2 |
| JS/JSX bundling | ✅ | ✅ | ✅ | ✅ |
| TypeScript | ✅ (via loader) | ✅ (native) | ✅ (native) | ✅ |
| SCSS/Sass | ✅ | ✅ (via plugin) | ✅ | ✅ |
| CSS Modules | ✅ | ⚠️ partial | ✅ | ✅ |
| Tree shaking | ✅ | ✅ | ✅ | ✅ |
| Code splitting | ✅ | ✅ | ✅ | ✅ |
| HMR | ✅ | ❌ (watch/serve only) | ✅ (excellent) | ✅ |
| GraphQL imports | ✅ (via loader) | ✅ (native) | ⚠️ plugin required | ⚠️ plugin required |
| Zero config | ❌ | ❌ | ✅ | ✅ |
| Rails integration | ✅ (webpacker) | ✅ (manual) | ⚠️ via vite-ruby | ❌ |
| Plugin ecosystem | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
One of the key reasons we chose esbuild was GraphQL support.
At the time of migration (~2024), our project relied heavily on importing .graphql files directly into frontend code.
This made esbuild a more predictable and reliable choice for our setup.
Vite is currently the most popular choice for new frontend projects (React, Vue, Svelte).
| Tool | npm weekly downloads | GitHub stars | Trend |
| Webpack | ~25M | ~65K | Stable, slowly declining |
| Vite | ~18M | ~70K | Fast-growing |
| esbuild | ~28M (indirect usage included) | ~38K | Stable, widely used internally |
| Parcel | ~1M | ~43K | Stable, niche usage |
However, esbuild is often used indirectly because many tools (including Vite itself) depend on it internally.
Choose esbuild if:
Choose Vite if:
Stay on Webpack if:
Esbuild is extremely fast, but it’s not a silver bullet. During and after the migration, we ran into a few real limitations that are worth understanding before adopting it in a production Rails setup.
Webpack and Sprockets both generate a manifest.json that maps logical asset names to their hashed filenames. esbuild does not. Without a manifest, Rails has no way to know that vendor.css is now vendor--JMBCZRRQ.css.
You have to write (and maintain) a custom hashed_asset_path helper that scans the filesystem at runtime. It works, but it's boilerplate that wouldn't exist with Webpack or Vite's Rails integration (vite-ruby).
CSS Modules (import styles from './Component.module.scss') are a zero-effort feature in Webpack. In esbuild, you can't import a stylesheet as a JS object and use styles.myClass as a value in JSX.
Every component that used CSS Modules had to be refactored:
.module.scss → .scssstyles.*In a large codebase, this is significant manual effort, and it's easy to introduce visual regressions if classes aren't renamed consistently.
Occasionally, a library ships CSS that uses old import syntax or non-standard constructs that esbuild's SCSS pipeline can't handle. In our case, Select2 had legacy styles that refused to import cleanly.
The workaround: copy the entire vendor CSS into a local file in the project and import that instead. It works, but it means you're now maintaining a snapshot of third-party style updates to the library that won't automatically reflect.
This is rare, but worth knowing before you start the migration.
Esbuild has a --serve mode and a --watch mode, but no Hot Module Replacement. On file change, the bundle rebuilds (~milliseconds), but the browser does a full page reload. HMR is explicitly out of scope for the project — there's a long-standing GitHub issue #464 with no plans to implement it.
For a Rails app, this is usually fine; you're not building a pure SPA where HMR saves seconds on every edit. But if your team is used to React Fast Refresh, the DX step-down is noticeable.
Esbuild transpiles TypeScript by stripping types, and moving on it does zero type checking. This is intentional and is why it's so fast.
In practice, you need to run tsc --noEmit as a separate step in CI. If someone skips or misconfigures that step, type errors will silently pass through to production.
Esbuild is essentially a one-person project (Evan Wallace, ex-Figma). It has been actively maintained since 2020, but there's no foundation, no corporate sponsor, and no formal contribution committee. For some teams, this is a governance risk for a core build dependency.
The plugin API does not expose the AST, so plugins can only hook into load/resolve steps — arbitrary code transforms are not possible. Many webpack use cases (custom Babel macros, codemods-as-plugins) simply cannot be replicated. And JS-written plugins run outside esbuild's Go core, which can partially negate the speed advantage if plugins do heavy work.
The migration from Webpack to esbuild in this Rails + React project took around 2 weeks of focused work and resulted in significant improvements across both performance and maintainability.
We achieved:
react_on_rails gem dependency.module.scss required renaming CSS classes to global BEM-style stringsFor a production Rails application with legacy code, esbuild proved to be the right tool: predictable, fast, and flexible enough to handle custom asset pipelines without framework opinions.