Cover for Bundlebun bundles Bun, and a tour of asset pipelines and JavaScript runtimes Rails has been through

Bundlebun bundles Bun, and a tour of asset pipelines and JavaScript runtimes Rails has been through

  • Ruby
  • Ruby on Rails
  • JavaScript

This post introduces Bundlebun, a gem that packs Bun—all-in-one JavaScript runtime, package manager, and build tool—directly into your Gemfile. No Docker, no curl | sh, no Homebrew required, and everyone gets a JavaScript runtime after bundle install with the exact version pinned.

Check the repository and README, or run bundle add bundlebun followed by rake bun:install to try it.

But first, we go down memory lane to remember how asset pipelines worked with every Rails version and Rails’ relationship with JavaScript runtimes, and the #nobuild movement introduced in the latest versions of Rails.

How it started

It all started with a Node version mismatch.

There was an internal Ruby on Rails application I joined, initially implemented by an engineer wildly overqualified for the job. The app had a Dockerfile; in fact, there’s a sprawling, once-a-year-updated write-up on how to dockerize Rails applications. Problem is, not many people love spinning up Docker containers for development, especially on tiny Rails applications with barely any frontend—and this one had a simple frontend.

Long story short, one of us had an LTS version of Node.js, another had the latest stable one. The app only worked with a specific version of Node.js, assets weren’t building properly, and the bootstrap process broke completely: the app didn’t start. It took us some time to figure out why and fix it.

This got me thinking: how is it possible we’re two decades into Ruby on Rails, and overqualified engineers still stumble into the problem of agreeing on a specific version of a JavaScript runtime and packaging assets for an application with a simple frontend? Surely there’s a better way—one developers would willingly reach for.

Ways to solve the issue

Let’s write the problem down:

  • Make sure every developer of the app has a version of JavaScript runtime and the necessary tooling;
  • Make sure that everyone is on exactly the same version of said runtime and tooling.

First of all, Docker definitely solves both issues, but I don’t know that many developers who love to spin a Docker container for every local Rails app, no matter how small. For larger apps, sure, but we’re focusing on small and mid-sized apps with a simpler frontend for now.

curl | sh scripts are both optional and don’t guarantee a specific version is installed.

Linux package managers like apt, dnf, or pacman come with the OS, but you can’t assume which one—and the version they ship often lags upstream.

Homebrew is optional, tracks upstream closely, but isn’t really built for project-scoped version pinning.

Mise, asdf, and other version managers solve the version pinning problem for sure. Still optional by design, though: we cannot force developers to install them other than forcing them to run the bootstrap script.

Now, in case you are reading this from the future where Nix and Flakes are the winning way to run development environments, I salute you. Sadly, we’re not there yet. Still the same “optional tool” issue.

Devcontainers are a massive step in the right direction, but shine only when run from an IDE with Devcontainers support (VS Code, IntelliJ IDEA, Zed). Others are left with the CLI, which is a bit lacking.

Well, there should be a simpler way to ship a JavaScript runtime and builder with Ruby on Rails, and the community has tried for the best part of twenty years. Why aren’t we there yet?

Rails history with asset pipelines and JavaScript runtimes

In Rails 2, there was no asset pipeline; JavaScript and CSS lived in public/javascripts and public/stylesheets with supporting helpers.

Sprockets is where the JavaScript runtime requirement was born, in Rails 3.1. The asset pipeline had app/assets/javascripts, manifest files, and CoffeeScript and Sass as the default authoring formats. CoffeeScript needed to be compiled to JavaScript, Sass to CSS, and that compilation happened at request time in development and at deploy time in production. To do that, Sprockets needed a JavaScript engine, ExecJS: a small gem that abstracts over a currently available JavaScript runtime, picking V8 via therubyracer or mini_racer, Node.js, or JavaScriptCore. This is where the JavaScript runtime requirement starts.

Rails 4 introduced Turbolinks, which made full-page swaps feel like an SPA without the full-blown JavaScript framework, and continued to use Sprockets.

Rails 5 added ActionCable for WebSockets, but we were still using Sprockets. While we’re on this topic, the JavaScript world grew dramatically; everyone used npm, Webpack, and Babel.

In Rails 5.1, that was addressed: Webpacker was introduced, a gem wrapper for Webpack, with Yarn for package management. It was opt-in and you could use both it and Sprockets.

In Rails 6.0, Webpacker became the default for JavaScript: new apps got app/javascript, a package.json, a node_modules directory, and the requirement that the deploy machine has Node. At this point, we had a single app with two pipelines (Sprockets was still the default for CSS). Webpack itself moved fast and broke configs; the Webpacker maintainers tried to keep up but the dependency tree of a fresh Rails 6 app was sometimes massive even for an empty app.

Rails 7.0 deprecated Webpacker and replaced it with three new gems: importmap-rails (ESM without a bundler), jsbundling-rails (a wrapper that allowed you to choose esbuild, rollup, or Webpack), and cssbundling-rails (same, but for Tailwind, PostCSS, dart-sass, or Bootstrap). At this point, the Hotwire stack became the default for Rails frontend. Propshaft was introduced as a slimmer Sprockets alternative.

Rails 8.0 committed to the no-build approach (branded as #nobuild) fully: Propshaft became the default asset pipeline, the standalone Tailwind binary handled CSS without Node.js, and the new-app generator pointed at importmap-rails by default. The pitch: a new Rails 8 app needs Ruby and nothing else—no JavaScript runtime for deployment.

Is this the end of the story and do Hotwire and #nobuild end the cycle of reinventing frontend within Rails? Stimulus, one of the key components of Hotwire, had its latest point release in… August 2023, almost three years ago. Version 3.2 was released in November 2022, and version 3.0 saw the light in September 2021. Are we mid-cycle with something new coming in a year or two to repeat the cursed cycle? Who knows.

But, more importantly, what’s wrong with #nobuild?

#nobuild

I wish things always worked as advertised—the return to simple, no-build, no-Node.js frontend applications would be romantic. It’s the closest thing to a circa-2007 application.js we’ve had.

For some apps, no-build works. For most apps that touch any meaningful JavaScript ecosystem, the limits start piling up.

HTTP/2 does not save you

The strongest argument for no-build is HTTP/2 multiplexing. Webpack was invented, among other things, to amortize the connection cost of fetching dozens of small files, and HTTP/2 made that cost roughly free, so why bundle them anymore?

In practice, the issue is the shape of an ES module graph rather than the cost per request. The browser parses your app.js, finds import { foo } from "./foo.js", and can’t request foo.js until it has finished parsing app.js. When foo.js imports more modules, those can’t start until foo.js parses too. HTTP/2 makes the requests at any one level cheap, but it doesn’t change the fact that the levels run in series. A typical React-shaped app—framework, state library, UI library, utilities, components—has a dependency several levels deep. On a 50 ms RTT connection that’s a few hundred milliseconds of pure waterfall before the first frame paints. <link rel="modulepreload"> helps, but only if you hand-list every module the entry point will eventually reach. A bundle replaces all of that with one round trip and a single parse pass. It’s tolerable for prototypes and internal tooling, but not great otherwise.

Compression doesn’t help unbundled code either. Brotli on a 200 KB bundle reaches ratios in the 4–5× range because the algorithm has the whole graph as its corpus and finds repeated patterns across files. Split that same code into a hundred separate ESM files, each compressed independently, and you lose the cross-file dictionary while still paying per-response framing overhead. Wire bytes for an unbundled app routinely run 30%–50% above the bundled equivalent on identical source.

There’s also tree-shaking, which doesn’t happen in importmap-land. If a library exports ten functions and you import one, the browser fetches and parses the other nine. Lodash, date-fns, RxJS, lucide-react, anything with “icons” in its name—a lot of the modern frontend ecosystem ships generously, on the assumption that the build pipeline will pick what’s used.

The “importmaps win on repeat visits” claim depends on releases rarely changing the cached parts, which is true for leaf modules and not very true for entry points and shared utilities. A hash change near the top of the graph means the browser revalidates downstream files even when their contents are unchanged. A bundle ships one new hash, busts one cache entry, and is done.

Overall, #nobuild ends up slower on first visit, slower on most repeat visits, heavier on the wire, and adds parser pauses that bundlers eliminated a decade ago.

And then there’s the ecosystem

Performance is half of it. The other half is that the JavaScript ecosystem most teams actually want to use simply doesn’t run in importmap-land.

TypeScript is the most common collision. A lot of modern JavaScript libraries, from utility kits to entire frameworks, ship .ts source as the canonical form and let consumers transpile. Importmaps don’t load .ts; you either avoid TypeScript libraries (a shrinking option) or bring back a compile step, which means you have a bundler. JSX, Vue templates, and Svelte components have the same problem. Every popular component framework expects a transform pass before its source becomes browser-loadable JavaScript. Pre-compiling to .js and committing the output to your static directory technically works, but you’ve now reinvented bundling by hand.

The npm package ecosystem assumes a bundler in subtler ways too. A surprising fraction of libraries still ship CommonJS, sometimes alongside ESM, sometimes only CommonJS. Bundlers paper over this with interop shims; importmaps can’t, and the workarounds put your dependency graph at the mercy of third-party CDN-built ESM mirrors.

Tailwind is its own category. The standalone CLI handles vanilla utilities fine, and the moment you reach for a plugin that doesn’t ship as a binary—DaisyUI, custom variants, component sets—you’re back to Node. Tailwind 4 packs more into the binary, but plugin authors can only follow as far as the embedded toolchain reaches.

And then the libraries themselves: shadcn/ui, Three.js loaders, Zod schemas, the whole @tanstack/* family. Each one expects a build pipeline. The gap between “works in importmap” and “works in any modern Node tutorial” widens with every release. The cost isn’t only the learning curve—it’s that the set of packages your team can adopt without an architectural debate keeps shrinking.

The thing that doesn’t get mentioned often is that #nobuild doesn’t fully eliminate building. It moves the work. Importmap pin lists, modulepreload hints, CDN provider choices, version freezes spread across HTML files, the script that regenerates the importmap manifest when you upgrade a dependency. It’s all still configuration.

#nobuild is genuinely great if the app you are building has a simple frontend and you don’t need many frontend libraries. The moment this changes, you’re back to picking—or reinventing—a bundler.

So, let’s pick a runtime and a bundler.

Why Bun

Bun is a JavaScript runtime—much like Node.js or Deno—created by Jarred Sumner in 2021. Lately, Bun has been everywhere in the news because Anthropic (of Claude fame) acquired it in 2025. In fact, Claude Code, potentially your favorite tool at the moment, works on Bun.

Bun is a joy to work with because it prioritizes developer experience, and, specifically, the fun of development. To quote their 1.0 release post,

Bun’s goal is simple: eliminate slowness and complexity without throwing away everything that’s great about JavaScript. Your favorite libraries and frameworks should still work, and you shouldn’t need to unlearn the conventions you’re familiar with.

Bun bills itself as the fastest JavaScript runtime available. But Bun is not only a runtime—it is also a super-fast package manager that can replace npm, yarn, or pnpm. It is also a bundler, albeit at the moment not as featureful as, say, Vite, but more than enough for simpler frontend applications. So, the entirety of Node.js + npm + esbuild + ts-node is replaced by a single product.

What also helps is that Jarred used to do Rails. More than that:

What’s also important for us: Bun is distributed as a single binary. So, a single executable file that is a JavaScript runtime, a package manager, and a builder—bingo.

Still, to get all that beauty, you need to install it on every developer machine, and make sure all developers have the same version—as Bun can have releases as frequent as every couple of weeks. If the app was tested with a specific version of Bun, we need to make sure that every developer has it installed, and that it is possible to use it for deployment as well. We need to pin a version and make sure installation works.

We’ve already gone through some options to install and maintain version pinning, and most of them are not a perfect match. So what do we do?

Pack the executable into a gem.

Meet Bundlebun

Bundlebun is a Ruby gem that contains the Bun binary—the official artifact published by the Bun team, for your CPU architecture and operating system, packaged inside the gem’s release file. You add it the way you add any gem:

bundle add bundlebun

And then you run

rake bun:install

The first command pulls the gem with the right Bun for your machine. The second drops a bin/bun binstub into your repo, and installs integrations—we’ll get back to that later. From then on, anyone who clones the project has Bun the moment they run bundle install. They don’t install Node. They don’t install Bun separately.

Now, about version pinning. A gem version 0.1.0.1.1.38 parses as gem version 0.1.0 shipping Bun 1.1.38—the first three numbers are the gem’s, the last three are Bun’s, glued on automatically every time Bun publishes a release. Bun moves and the gem moves with it; there’s no maintainer in the middle deciding when to catch up. Your Gemfile.lock records exactly which Bun version your team is on, the same way it records Rails. Want the latest? Leave the gem unconstrained. Want to pin? The ~> operator you already know keeps you on specific Bun versions. Or, freeze the version completely. The mechanism is the one Ruby developers have used since 2010—it now covers JavaScript too.

What does this buy you in practice

A new developer joins the project. They clone the repo, run bundle install, and have Bun on their laptop. Five minutes from clone to running app, with no detour through a “set up your environment” document. The same Gemfile.lock that pins Rails pins their Bun.

CI loses a step. No setup-node@v4, no separate cache key for Node modules, no version drift between local bun --version and what GitHub Actions thinks Bun is. The bundle install step that was always there now produces a working Bun, so the step that runs bin/bun build just works. A workflow that used to spend twenty seconds setting up Node spends two extracting the gem cache.

Production deploys lose a RUN apt-get install -y nodejs line from the Dockerfile, or whichever PaaS-buildpack equivalent you’d been using. The image gets smaller. There’s one fewer “where did this binary come from” question for your security team to ask.

The compound effect can be real for any team that’s spent meaningful time on environment-setup issues—which is probably most teams.

What it does to existing Rails-Node bridges

The bigger payoff is that the gems already gluing Rails to Node-shaped tools just use Bun now. You don’t switch frontend stacks—you swap the runtime underneath them.

The integrations are already installed once you run rake bun:install. You can also run them selectively.

Running bun:install offers to automatically update your package.json and Procfile (if you have one) so they invoke the new, bundled Bun in place of whatever was previously wired up. Decline the prompt to leave them as-is.

If your project uses vite-ruby, the install step alters your vite.json to point at the bundled binstub, and the next bin/vite runs Vite on Bun. Vite is still Vite—same plugin ecosystem, same config, same hot reload. The package install step that used to take twenty seconds now takes two, and builds shave a few seconds off too.

If you use cssbundling-rails or jsbundling-rails—the Hotwire-era gems for delegating frontend tooling to Node—Bundlebun replaces that delegation. During the installation process, the underlying toolchain is swapped to bin/bun. The bin/dev Rails teams have been running for years keeps working; the asset pipeline just stops requiring a separate Node install.

ExecJS lets Ruby execute JavaScript on the server—the engine behind libraries like react-rails, the legacy template-rendering choices a lot of teams have forgotten they have, and a long tail of “we only need to run JavaScript at boot to compile a thing once” use cases. If you load Bundlebun after ExecJS in your Gemfile, ExecJS will detect the bundled Bun as a runtime, and your server-side JavaScript executes under bundled Bun.

Using it

bin/bun is the day-to-day surface, and it behaves exactly like the Bun CLI:

bin/bun install
bin/bun add postcss
bin/bun run build

For one-off commands inside Rails’s task surface, there’s a Rake wrapper (just notice the Rake syntax):

rake "bun[outdated]"

For Ruby code that needs to call Bun from inside the application—a release task, a custom build step, a cron job—there’s a small Ruby API:

Bundlebun.('install')      # exec form, replaces the Ruby process, 
                           # for use in binstubs, shell files, tasks

Bundlebun.system('test')   # spawns Bun, returns control to Ruby

ActiveSupport instrumentation publishes system.bundlebun events, so the same dashboard that watches your N+1 queries can watch your Bun commands:

ActiveSupport::Notifications.subscribe('system.bundlebun') do |event|
  Rails.logger.info "Bun: #{event.payload[:command]} (#{event.duration}ms)"
end

However, most of the time, you would only use bin/bun and automatic integrations.

Behind the scenes

The user-facing part of the gem is rather trivial: executing Bun one way or another, plus hacks for integrating with existing libraries. What is more interesting are some gotchas I’ve discovered along the way. All the interesting stuff happens in build and testing.

Automatic releases

If there are no code changes in the gem itself, the release process is fully automatic. When a new Bun release is out, there is no gem author pushing a button behind the scenes.

Instead, there is a task that is run every day by a cron job in GitHub Actions.

The task, using the GitHub API, checks if there is a new Bun release out, by comparing it with the last group of digits from our own latest release. Meaning, if the latest Bun release is v1.3.14 and our own gem version is v0.5.0.1.3.13, there is a mismatch, and a new gem version should be released.

For every supported platform, a Bun executable is downloaded and packed into a platform- and architecture-specific gem. Next, again, for every platform, smoke tests are run by asking Bundlebun, essentially, what is 2 + 2 (bun -e 'console.log(2+2)'). If the script is satisfied with the result, a gem release is pushed.

The workflow and packing tasks can save you some time if you are thinking about doing a similar binary-in-gem project.

Integration-integration tests

An important part of Bundlebun is its plug-and-play integration into an existing Ruby on Rails application, hijacking the calls to a JavaScript runtime in existing libraries.

We have to test the behavior between various Ruby and Rails versions. Unit testing the hijack module would mostly be useless code duplication; we must be sure that overloading works in practice and the Bun executable is run with specific parameters—or that the result of some asset pipeline looks exactly as expected.

That’s why instead of going the traditional integration testing route with something like the combustion gem, in some of the tests I had to automatically generate an actual Rails application, add bundlebun to Gemfile, install gems, and run the install Rake task. Next, it is possible to compare the result of, say, going through an asset pipeline and building CSS with the expected one. Real Rails application, real assertions, no surprises when a new vite-ruby or Rails version comes out and something breaks.

There were several gotchas with how Bundler works in this regard (bundle runs our testing suite, which generates a directory where another bundle should run, quite messy), so in case you are writing a Rails plugin that requires this level of testing, again, re-using that code could possibly save some time.

Native Windows support

Lots of Ruby gems ignore the existence of Windows by not running a Windows machine on CI at all. A typical answer to that is to use WSL or Docker. In my opinion, not good enough—if it is possible to do a gem build for Windows, we should also test its correctness on the native platform and not rely on WSL. We need to take inspiration from the Rust community, where lots of top-tier developers use Windows natively and the OS is not considered a “second-class citizen”.

Now, Bun supports Windows natively, so it was a separate task for me to ensure the gem works natively on Windows. That means: bun.cmd binstubs instead of just bun, PATH gotchas and other issues.

Time to try it

Finally, time to try Bundlebun! In an existing Rails app:

bundle add bundlebun
rake bun:install

Don’t forget to read the README at GitHub: yaroslav/bundlebun.

And don’t hesitate to ping me on X with feedback and questions.