The go-to resource for upgrading Ruby, Rails, and your dependencies.

Optimizing Frontend Assets with ESBuild and Rollup in Vite Rails


In the 1950s, global shipping was a chaotic and labor-intensive process. Cargo came in barrels, sacks, and wooden crates of every conceivable size. Loading a single ship could take weeks of manual labor, and tracking the diverse freight was a logistical nightmare. A solution arrived in the form of the standardized shipping container. By uniformizing the packaging, the entire logistics network — cranes, trucks, and ships — could operate with unprecedented speed and efficiency. The container standardized the packaging and delivery mechanism for global trade.

Similarly, our Ruby on Rails applications are composed of diverse assets: JavaScript modules, CSS stylesheets, images, and fonts. For years, Rails developers relied on tools like the Asset Pipeline (Sprockets) or Webpacker to manage this cargo. Today, though, many teams are transitioning to Vite Ruby. Much like the shipping container revolutionized logistics, Vite relies on two specialized tools — ESBuild and Rollup — to dramatically accelerate both how we develop and how we deliver these assets to the user’s browser.

Optimizing frontend assets reduces the payload size that clients must download, which improves the initial render time of your Ruby on Rails application. Before we get into that, though, let’s examine how Vite divides the labor of asset bundling.

The Philosophy of Dual Bundlers

One may wonder: if we are using Vite for our Rails frontend, why does it utilize both ESBuild and Rollup under the hood? The answer is straightforward: they solve different problems for different environments.

During development, compilation speed is the primary constraint. We want our code changes to reflect in the browser immediately. ESBuild, written in Go, is exceptionally fast at compiling JavaScript and TypeScript. However, it trades some advanced optimization capabilities to achieve this speed.

In production, though, the constraint shifts entirely. The focus shifts to payload size, caching, and network efficiency. Rollup excels at this. It generates highly optimized, tree-shaken bundles and provides fine-grained control over how code is split into separate files, or “chunks.”

Inspecting the Build State

Before we apply any optimizations, it is wise to understand what is actually consuming space in your bundles. We can inspect the state of our production build using the rollup-plugin-visualizer. This tool gives us a concrete map of our dependencies.

First, we add it to our project:

$ yarn add --dev rollup-plugin-visualizer

We can then add it to our Vite configuration. Open your vite.config.ts file:

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    RubyPlugin(),
    visualizer({ filename: 'tmp/stats.html' })
  ]
})

When you run bin/vite build, this plugin generates an HTML file. Opening this file in your browser reveals exactly which dependencies are bloating your payload. You also may notice duplicate versions of the same library, which frequently occurs when different dependencies require conflicting minor versions of a shared package. By seeing exactly what Rollup is packaging, we can make informed decisions about how to split our code.

Optimizing Production with Rollup Chunking

By default, the vite_ruby gem provides a solid foundation. Of course, out-of-the-box settings are designed to be universally applicable, which means they are rarely fully optimized for your specific application’s scale.

Rollup might package all of your vendor dependencies (everything in node_modules) into a single, massive JavaScript file. We can leverage Rollup’s manual chunking to optimize how the browser downloads and caches these assets. There are two major approaches to chunking dependencies: separating all vendor code into one file, or creating specific chunks for individual libraries.

The first option is to separate our vendor code from our application code entirely:

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'

export default defineConfig({
  plugins: [
    RubyPlugin(),
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        }
      }
    }
  }
})

This configuration intercepts the build process and bundles all dependencies from node_modules into a single vendor.js file. This represents an improvement over a monolithic bundle. Your application code changes frequently, while your vendor dependencies change rarely. By separating them, the browser can cache the vendor chunk long-term, only downloading the smaller application chunk when you deploy new features.

Advanced Chunking Strategies

The second option is to create specific chunks for heavy dependencies. If your Rails project includes massive frontend libraries, a single vendor chunk might still be too large, causing a bottleneck on initial load. We can refine this strategy further by splitting out specific, heavy dependencies:

export default defineConfig({
  plugins: [
    RubyPlugin(),
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('lodash')) {
              return 'lodash';
            }
            if (id.includes('@hotwired/turbo')) {
              return 'turbo';
            }
            if (id.includes('vue') || id.includes('react')) {
              return 'framework';
            }
            return 'vendor';
          }
        }
      }
    }
  }
})

This approach, strictly speaking, creates multiple distinct vendor chunks. Modern browsers utilizing HTTP/2 can download these smaller chunks in parallel with great efficiency. Furthermore, an update to lodash will not invalidate the browser’s cache for your framework or turbo chunks. Generally speaking, the first option is simpler. The second option, though, will often make more sense if your application bundles multiple large frameworks.

Optimizing ESBuild Target Environments

While Rollup handles the production chunking, ESBuild handles the syntax transformation. When ESBuild processes your modern JavaScript, it looks at the build target to determine how far back it needs to transpile the code for older browsers.

We can optimize how much transformation occurs by explicitly specifying our target browsers in vite.config.ts:

export default defineConfig({
  plugins: [
    RubyPlugin(),
  ],
  build: {
    target: 'es2015',
  }
})

If we target es2015 or newer (such as esnext or modules), ESBuild can leave modern JavaScript features intact rather than transpiling them down to verbose ES5 syntax. This results in smaller file sizes and faster execution times in the browser.

Of course, you must ensure that your target audience’s browsers support the standard you select. If you are building an internal dashboard for a company that mandates modern browsers, you can safely target esnext. If your application serves a broad public audience, es2015 or modules offers an excellent balance between modern efficiency and widespread compatibility.

Long-Term Asset Maintainability

Optimizing Vite Rails is an ongoing process. As your application grows and new gems or NPM packages are added, your asset bundles will naturally expand.

By periodically running the visualizer plugin and adjusting your Rollup chunking strategies, you ensure that your Rails application remains fast and responsive. Efficient asset delivery reduces server bandwidth costs, improves Core Web Vitals, and respects your users’ time and network constraints.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming