<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.fastruby.io/blog/rss.xml" rel="self" type="application/atom+xml" /><link href="https://www.fastruby.io/blog/" rel="alternate" type="text/html" /><updated>2026-06-14T08:56:19-04:00</updated><id>https://www.fastruby.io/blog/rss.xml</id><title type="html">The Rails Tech Debt Blog</title><subtitle>FastRuby.io | Rails Upgrade Service</subtitle><author><name>OmbuLabs</name></author><entry><title type="html">How to Leverage PurgeCSS in Your Rails App for Faster Stylesheets</title><link href="https://www.fastruby.io/blog/how-to-leverage-purgecss-in-your-rails-app-for-faster-stylesheets.html" rel="alternate" type="text/html" title="How to Leverage PurgeCSS in Your Rails App for Faster Stylesheets" /><published>2026-06-08T10:54:58-04:00</published><updated>2026-06-08T10:54:58-04:00</updated><id>https://www.fastruby.io/blog/how-to-leverage-purgecss-in-your-rails-app-for-faster-stylesheets</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-leverage-purgecss-in-your-rails-app-for-faster-stylesheets.html"><![CDATA[<p>It’s common for Rails applications to serve massive CSS files filled with unused Bootstrap, Tailwind, or custom utility classes as projects grow. This bloat isn’t just a developer annoyance—it has a real impact on your users. Every unused kilobyte adds milliseconds to page load time. In this post, we’ll explore what <a href="https://purgecss.com">PurgeCSS</a> is and how your Rails project can benefit from it.</p>

<!--more-->

<h2 id="what-is-purgecss-and-why-should-you-care">What Is PurgeCSS and Why Should You Care?</h2>

<p>PurgeCSS is a tool that analyzes your content and CSS, then removes unused CSS selectors, stripping away dead weight to leave you with lean, optimized stylesheets.</p>

<p>For example, if you’re using the full Bootstrap CSS framework (approximately 200KB) but only utilizing 30% of its components, PurgeCSS can scan your HTML (ERB files), JavaScript (including Stimulus controllers), and other templates to identify which Bootstrap classes you actually use. It then generates a new CSS file containing only those classes, potentially reducing your Bootstrap footprint by 70% or more.</p>

<h2 id="benefits-for-your-rails-application">Benefits for Your Rails Application</h2>

<p>PurgeCSS improves performance by reducing the amount of data browsers must download, parse, and process—directly improving metrics like Largest Contentful Paint (LCP). Beyond faster load times, tools like Google PageSpeed Insights and Lighthouse reward lean assets, making unused CSS removal a high-impact, low-effort optimization.</p>

<p>This approach also enhances developer experience by allowing you to confidently use large CSS frameworks or generate extensive utility classes (like with Tailwind) without worrying about shipping bloated, unused code to production.</p>

<h2 id="implementing-purgecss-in-rails">Implementing PurgeCSS in Rails</h2>

<p>The cleanest way to integrate PurgeCSS into a Rails application is using the <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> gem, the modern successor to Webpacker for CSS (with <code class="language-plaintext highlighter-rouge">jsbundling-rails</code> covering the JavaScript side). It seamlessly integrates PostCSS and other build processes into <code class="language-plaintext highlighter-rouge">assets:precompile</code>.</p>

<h3 id="prerequisites-and-setup">Prerequisites and Setup</h3>

<p>First, ensure you’re using <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> to build your CSS. If you created a new Rails app with <code class="language-plaintext highlighter-rouge">rails new myapp --css=bootstrap</code>, you’re already using it.</p>

<p>For older applications, you may need to migrate. This guide assumes you have a setup that uses <code class="language-plaintext highlighter-rouge">yarn build:css</code> to process your styles.</p>

<h3 id="installation">Installation</h3>

<p>Since PurgeCSS is a PostCSS plugin, we’ll install it and add it to our PostCSS configuration. Add the package using Yarn:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>yarn add <span class="nt">-D</span> @fullhuman/postcss-purgecss
</code></pre></div></div>

<h3 id="configuration">Configuration</h3>

<p>Next, open (or create) your <code class="language-plaintext highlighter-rouge">postcss.config.js</code> file in your Rails project root. It’s important to only run PurgeCSS in production since running it in development would strip classes with every file change, severely impacting developer experience.</p>

<p>To gate it, we check <code class="language-plaintext highlighter-rouge">NODE_ENV</code> rather than <code class="language-plaintext highlighter-rouge">RAILS_ENV</code>. PostCSS runs inside the Node process that <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> spawns for <code class="language-plaintext highlighter-rouge">yarn build:css</code>, and <code class="language-plaintext highlighter-rouge">RAILS_ENV</code> isn’t reliably exported into that child process across every platform and CI setup. <code class="language-plaintext highlighter-rouge">NODE_ENV</code> is the signal the Node toolchain already understands, so make sure your production build sets <code class="language-plaintext highlighter-rouge">NODE_ENV=production</code> (Rails does this by default during <code class="language-plaintext highlighter-rouge">assets:precompile</code>).</p>

<p>Here’s a sample configuration:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// postcss.config.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">postcss-import</span><span class="dl">'</span><span class="p">),</span>
    <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">postcss-nesting</span><span class="dl">'</span><span class="p">),</span>
    <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">autoprefixer</span><span class="dl">'</span><span class="p">),</span>
    <span class="c1">// Only run PurgeCSS in production</span>
    <span class="p">...(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span> <span class="p">?</span> <span class="p">[</span>
      <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@fullhuman/postcss-purgecss</span><span class="dl">'</span><span class="p">)({</span>
        <span class="na">content</span><span class="p">:</span> <span class="p">[</span>
          <span class="dl">'</span><span class="s1">./app/views/**/*.html.erb</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">./app/helpers/**/*.rb</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">./app/javascript/**/*.js</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">./app/javascript/**/*.ts</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">./app/components/**/*.rb</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">./app/components/**/*.html.erb</span><span class="dl">'</span><span class="p">,</span>
          <span class="c1">// Add any other directories containing class names</span>
        <span class="p">],</span>
        <span class="na">defaultExtractor</span><span class="p">:</span> <span class="nx">content</span> <span class="o">=&gt;</span> <span class="nx">content</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">A-Za-z0-9-_:</span><span class="se">/]</span><span class="sr">+/g</span><span class="p">)</span> <span class="o">||</span> <span class="p">[],</span>
        <span class="na">safelist</span><span class="p">:</span> <span class="p">[</span>
          <span class="sr">/bg-</span><span class="se">(</span><span class="sr">red|green|blue</span><span class="se">)</span><span class="sr">-</span><span class="se">\d</span><span class="sr">00/</span><span class="p">,</span> <span class="c1">// Dynamic class patterns (e.g., bg-red-500)</span>
          <span class="dl">'</span><span class="s1">btn-primary</span><span class="dl">'</span><span class="p">,</span>
          <span class="dl">'</span><span class="s1">is-invalid</span><span class="dl">'</span><span class="p">,</span>
          <span class="sr">/^alert-/</span><span class="p">,</span>
          <span class="sr">/^modal-/</span><span class="p">,</span>
          <span class="c1">// Add classes added via JavaScript not found in static templates</span>
        <span class="p">]</span>
      <span class="p">})</span>
    <span class="p">]</span> <span class="p">:</span> <span class="p">[])</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="configuration-breakdown">Configuration Breakdown</h3>

<ul>
  <li><strong>content</strong>: This array tells PurgeCSS where to find CSS class names. We include ERB templates, helpers that generate HTML with classes, JavaScript files (including Stimulus controllers), and component directories.</li>
  <li><strong>defaultExtractor</strong>: This function extracts class names from your files. The provided regex handles various class name formats, including those with colons (like <code class="language-plaintext highlighter-rouge">sm:flex</code>). It’s a permissive starting point that errs toward keeping more tokens (it also matches <code class="language-plaintext highlighter-rouge">/</code>), so it’s safer than it is precise. If you’re on Tailwind, prefer Tailwind’s own extractor rather than rolling your own here.</li>
  <li><strong>safelist</strong>: Essential for preserving dynamically added classes. You can safelist specific strings (<code class="language-plaintext highlighter-rouge">'btn-primary'</code>) or use regular expressions (<code class="language-plaintext highlighter-rouge">/bg-(red|green|blue)-\d00/</code> for Tailwind color utilities).</li>
</ul>

<h3 id="testing-and-deployment">Testing and Deployment</h3>

<p>With configuration complete, build for production:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">RAILS_ENV</span><span class="o">=</span>production bundle <span class="nb">exec </span>rails assets:precompile
</code></pre></div></div>

<p>This runs your CSS build process with PurgeCSS enabled. Check the compiled CSS file in <code class="language-plaintext highlighter-rouge">public/assets</code>—you should see a dramatically smaller file compared to your development version.</p>

<p><strong>Crucially</strong>, thoroughly test in a staging environment before deploying to production. Click through every page and application state to identify any missing styles. If you find issues, add the missing classes or patterns to your <code class="language-plaintext highlighter-rouge">safelist</code> array.</p>

<h2 id="important-considerations">Important Considerations</h2>

<ul>
  <li><strong>Stage Before Production</strong>: PurgeCSS can be aggressive. Test in a staging environment that mirrors production data, as some classes may only appear under specific conditions or with certain data.</li>
  <li><strong>Use Safelist Liberally</strong>: When in doubt, add classes to your safelist. It’s better to preserve a few extra classes than to have broken styles.</li>
  <li><strong>Tailwind Users</strong>: If you’re using Tailwind CSS (via the <code class="language-plaintext highlighter-rouge">tailwindcss-rails</code> gem), you don’t need PurgeCSS at all. Tailwind dropped PurgeCSS back in v3 in favor of its JIT engine, which only ever generates the classes it finds in your <code class="language-plaintext highlighter-rouge">content</code> paths. Your job there is simply to keep those <code class="language-plaintext highlighter-rouge">content</code> paths in <code class="language-plaintext highlighter-rouge">tailwind.config.js</code> comprehensive so nothing you actually use gets missed.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Integrating PurgeCSS into your Rails asset pipeline is straightforward and offers substantial benefits. By systematically removing unused CSS, you deliver faster, more performant experiences to users while maintaining the developer-friendly workflow of comprehensive CSS frameworks.</p>

<h2 id="need-help-speeding-up-your-rails-app">Need Help Speeding Up Your Rails App?</h2>

<p>If you’d like a hand trimming unused CSS, optimizing your asset pipeline, or improving your app’s performance, we’d be happy to help. Send us a message over here: <a href="/#contactus">FastRuby.io Contact Form</a></p>]]></content><author><name>hmdros</name></author><category term="performance" /><summary type="html"><![CDATA[Large Rails apps often ship CSS bloated with unused framework classes. Learn how to add PurgeCSS to your asset pipeline to strip dead styles and speed up page loads.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how-to-leverage-purgecss-in-your-rails-app-for-faster-stylesheets.png" /><media:content medium="image" url="https://www.fastruby.io/blog/how-to-leverage-purgecss-in-your-rails-app-for-faster-stylesheets.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to Choose A Gem Wisely (To Prevent a Maintenance Nightmare)</title><link href="https://www.fastruby.io/blog/how-to-choose-a-ruby-gem.html" rel="alternate" type="text/html" title="How to Choose A Gem Wisely (To Prevent a Maintenance Nightmare)" /><published>2026-06-02T06:00:00-04:00</published><updated>2026-06-02T06:00:00-04:00</updated><id>https://www.fastruby.io/blog/how-to-choose-a-ruby-gem</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-choose-a-ruby-gem.html"><![CDATA[<p>Imagine this scenario: a developer added a pub/sub gem built on top of Sidekiq to handle background event broadcasting
in your company’s Rails app. At the time, it was a huge win: instead of building a custom job orchestration system,
they could drop in the gem, wire up a few events, and ship a feature in days instead of weeks.</p>

<p>Fast forward a few years: Sidekiq needed an update. You find out the gem wasn’t actively maintained anymore. But by
then, the entire application depended on it. Core features like sending notifications, syncing with third-party APIs,
and triggering billing logic all ran through this pub/sub layer.</p>

<p>Now you face a painful choice: either keep running on an unmaintained gem and risk breakage every time Sidekiq or
Rails is updated, or rip it out and refactor the app to use a supported approach.</p>

<p>What began as a new dependency to save time has turned into a critical piece of fragile infrastructure. The lack of
maintenance has turned what should have been a simple dependency update into a full-blown project.</p>

<p>How do we avoid getting into this situation in the first place? In this post, we’ll show you by digging into five
critical areas to check before you choose a new gem.</p>

<!--more-->

<p>When working with Ruby on Rails, it’s tempting to install a gem for every new feature. Gems are one of the best parts
of the Ruby ecosystem.</p>

<p>They save time, bring in expertise from the community, and help teams deliver quickly. But gems are also long-term
commitments. Every gem you add becomes part of your application’s foundation, for better or worse.</p>

<p>At our company, we focus on <a href="https://www.fastruby.io/monthly-ruby-maintenance">fixed-cost Ruby and Rails maintenance</a>.
A big part of that work involves keeping an eye  on gems: their security, their stability, and whether they still
make sense for the app.</p>

<p>Here are the areas we check when deciding whether to keep a gem: how well the gem has been maintained and whether
it’s active, the community surrounding it and how well it’s known, its licensing, its security, and its overall fit.</p>

<h2 id="do-you-even-need-a-gem">Do You Even Need a Gem?</h2>

<p>The five areas below help you size up a gem you’re planning to adopt. But sometimes the leanest, safest dependency is
the one you never add, so start with a more basic question: do you actually need this gem, or could you write the few
lines it would take yourself?</p>

<p>Mike Perham, the author of Sidekiq, makes this point well in
<a href="https://www.mikeperham.com/2016/02/09/kill-your-dependencies/">Kill Your Dependencies</a>:</p>

<blockquote>
  <p>Every dependency in your application has the potential to bloat your app, to destabilize your app, to inject odd
behavior via monkeypatching or buggy native code.</p>
</blockquote>

<p>Whether owning it yourself wins out depends on how much code it would take and how many edge cases you’d have to handle.</p>

<p>A small string helper or a thin wrapper around a single HTTP call is often a few lines you can own outright, with no
maintenance, security, or licensing tail to worry about.</p>

<p>A PDF renderer or a full OAuth flow is the opposite: enough edge cases that a well-maintained gem is the smarter bet.
The line moves with your app, but it’s worth drawing every time. Before you reach for the Gemfile, definitely consider
whether you can implement the minimal functionality yourself and skip the dependency entirely.</p>

<p>Here is the whole decision in one view, from “do I really need it?” all the way to “add it and keep an eye on it”:</p>

<p><img src="/blog/assets/images/how-to-choose-a-ruby-gem-decision-flowchart.png" alt="Decision flowchart for whether to add a dependency to your Rails app. Start: you need new functionality. Do you really need it at all? If no, do not add anything and kill it. If yes, can you implement it yourself with a few lines and few edge cases? If yes, write it yourself and own it. If no, does the gem pass your checks for maintenance, adoption, license, security, and fit? If no, find a better option or reconsider. If yes, add the gem and keep monitoring it over time." /></p>

<p>The rest of this post breaks down that last question, the checks we run before we trust a gem.</p>

<h2 id="1-maintenance-and-activity">1. Maintenance and Activity</h2>

<p>The biggest thing to look for is whether your new dependency is maintained. An abandoned gem may work today but become
a roadblock tomorrow when Ruby or Rails versions move forward.</p>

<p>Here are a few metrics to watch:</p>

<ul>
  <li>
    <p>Last release date: Check how recently the gem was updated. A project that hasn’t seen a release in years may no longer be
compatible with modern Ruby versions. For example, many gems built for Ruby 2.x break on Ruby 3.x because of
the <a href="https://www.fastruby.io/blog/fix-sneaky-argument-error-when-upgrading-ruby.html">breaking changes</a> between those
versions.</p>
  </li>
  <li>
    <p>Commit history: A steady stream of commits, even small ones, shows that the project is still alive. In contrast, a repo
with one commit in the past two years likely means the maintainers have moved on.</p>
  </li>
  <li>
    <p>Open issues and pull requests: A backlog of unresolved issues can be a red flag. If critical bugs linger without
maintainer response, you may end up maintaining the gem yourself. On the other hand, active triaging and merged pull
requests signal a healthy project.</p>
  </li>
</ul>

<p>For gems you already depend on, <code class="language-plaintext highlighter-rouge">bundle outdated</code> gives you a quick snapshot of how far behind you are, and
<code class="language-plaintext highlighter-rouge">gem list --remote &lt;name&gt;</code> shows the available versions and their release dates. We dug into this across some of the most
popular gems in the ecosystem in
<a href="https://www.fastruby.io/blog/how-outdated-are-these-popular-ruby-projects.html">How outdated are these popular Ruby projects?</a>,
and the results were surprising even to us.</p>

<h2 id="2-community-and-adoption">2. Community and Adoption</h2>

<p>Again, these aren’t perfect metrics, but they can help you gauge whether your gem will be maintainable in the long term.</p>

<ul>
  <li>
    <p>Downloads and stars: These metrics indicate how widely the gem is used and trusted. A gem with millions of downloads or thousands
of GitHub stars is less likely to vanish overnight.</p>
  </li>
  <li>
    <p>Ecosystem mentions: Does the gem appear in blogs, tutorials, or other projects? Broad adoption usually means more eyes on the code,
quicker bug reports, and shared knowledge on how to use it.</p>
  </li>
  <li>
    <p>Forks and contributors: A project with multiple active contributors is less dependent on a single maintainer. If one person drops out,
others can keep it alive. A gem with a single owner and no active forks is far more fragile.</p>
  </li>
</ul>

<p>Where do you find these numbers? The gem’s page on <a href="https://rubygems.org">RubyGems.org</a> shows total and recent downloads,
<a href="https://libraries.io">Libraries.io</a> gives each project a SourceRank score based on its adoption and maintenance signals, and a repository’s
GitHub “Insights” tab shows the commit and contributor activity over time. None of these is decisive on its own, but together they
paint a picture.</p>

<h2 id="3-licensing">3. Licensing</h2>

<p>Licensing may not be glamorous, but it can have real consequences.</p>

<ul>
  <li>Open source license: Always check the license to ensure it’s compatible with your project. Most gems use permissive licenses like MIT,
which are fine for commercial use. But occasionally you’ll find more restrictive licenses (like GPL) that could create legal hurdles for
commercial projects. Knowing this up front saves pain later.</li>
</ul>

<p>If you want to audit the whole dependency tree instead of one gem at a time, the <a href="https://github.com/pivotal/LicenseFinder">license_finder</a> gem
scans your bundle, lists the license of every dependency, and lets you whitelist the ones your organization has approved so a surprise GPL
transitive dependency fails your build instead of your legal review.</p>

<h2 id="4-security">4. Security</h2>

<p>When adding a gem to your Rails application, you’re not just trusting the code you see, you’re also trusting the maintainer’s track record and
the entire dependency chain that comes with it.</p>

<ul>
  <li>Vulnerability reports: Before adopting a gem, check for known security issues in sources like the <a href="https://github.com/rubysec/ruby-advisory-db">Ruby Advisory Database</a> or CVE listings.</li>
</ul>

<p>The easiest way to do this is to run <a href="https://github.com/rubysec/bundler-audit">bundler-audit</a> against your project (<code class="language-plaintext highlighter-rouge">bundle-audit check --update</code>), which
cross-references your <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> against that same advisory database and flags any vulnerable versions.</p>

<p>Just as important: look at how quickly those issues were patched. For example, <a href="https://github.com/sparklemotion/nokogiri">Nokogiri</a>, one of the most widely
used gems in Rails apps, has had several high-profile security issues over the years. But it’s also an excellent case study in good maintenance: the team
consistently issues patches quickly, often within days. That responsiveness makes it a safe choice despite past vulnerabilities.</p>

<ul>
  <li>
    <p>Dependency chain: Gems often depend on other gems, which may in turn depend on others. This creates a web of transitive dependencies, each one
potentially carrying risks. For instance, using <a href="https://github.com/omniauth/omniauth">OmniAuth</a> can bring in multiple third-party strategies as sub-gems,
some maintained better than others. Without oversight, one poorly maintained dependency could expose the entire authentication flow to risk. A regular
maintenance process ensures these dependencies are reviewed, patched, or swapped out before they become liabilities.</p>
  </li>
  <li>
    <p>Supply chain risk: The maintainer’s account is part of the attack surface too. A gem with a single owner, no two-factor authentication, and the ability
to push directly to RubyGems is one compromised credential away from shipping malicious code to everyone who depends on it. We covered several real examples
and how to defend against them in
<a href="https://www.fastruby.io/blog/hidden-dangers-in-your-gemfile.html">The Hidden Dangers in Your Gemfile: Supply Chain Attacks in RubyGems</a>.</p>
  </li>
</ul>

<p>If you want to go further, <a href="https://www.fastruby.io/blog/how-to-use-brakeman-to-find-rails-security-vulnerabilities.html">Brakeman</a> and the rest of the
<a href="https://www.fastruby.io/blog/ruby-security-toolkit.html">essential Ruby security tools</a> we rely on can catch issues that a gem introduces into your own
application code.</p>

<h2 id="5-stability-and-fit">5. Stability and Fit</h2>

<p>Even if a gem is secure, it has to be the right fit for your application. Stability, maturity, and alignment with your actual needs all play a role in making
that judgment.</p>

<ul>
  <li>API maturity: A gem that changes its API with every minor release can create constant churn in your codebase. Look for signs of maturity such as semantic versioning, a predictable release cadence, and clear deprecation policies.</li>
</ul>

<p>A quick read of the <code class="language-plaintext highlighter-rouge">CHANGELOG</code> tells you a lot here: are breaking changes called out clearly, or do they show up unannounced in patch releases? For example,
<a href="https://github.com/heartcombo/devise">Devise</a> has been around for years, with a mature API and strong community support. You can rely on it for
authentication without fearing constant breaking changes. In contrast, newer authentication gems might be moving fast but lack the stability you need in production.</p>

<ul>
  <li>
    <p>Documentation and tests: Good documentation isn’t just about developer convenience; it’s a marker of quality and maintainability. Similarly, a gem with a
comprehensive test suite signals that the maintainers care about stability and regression safety. <a href="https://github.com/rspec/rspec-rails">RSpec</a> is a great
example: its extensive docs and test coverage make it a reliable foundation for testing, even in large, complex applications.</p>
  </li>
  <li>
    <p>Alignment with your needs: Pulling in a massive gem when you only need one small feature is like renting a moving truck just to carry a single box. The
overhead in maintenance and potential vulnerabilities rarely pays off. For example, developers often reach for
<a href="https://github.com/railsadminteam/rails_admin">RailsAdmin</a> or <a href="https://github.com/activeadmin/activeadmin">ActiveAdmin</a> when they only need a very
basic admin interface.</p>
  </li>
</ul>

<p>Those gems are powerful, but they’re also heavy, with lots of dependencies and DSL conventions. In many cases, building a few CRUD screens with plain Rails is
safer, lighter, and more maintainable.</p>

<h2 id="a-quick-gem-vetting-checklist">A quick gem-vetting checklist</h2>

<p>Before you add <code class="language-plaintext highlighter-rouge">gem "shiny_new_thing"</code> to your Gemfile, run through this:</p>

<ul>
  <li>✅ Has it had a release in the last year, with a steady commit history?</li>
  <li>✅ Are issues and pull requests being triaged, or is there a graveyard of stale ones?</li>
  <li>✅ Does the download count and contributor list suggest it will outlive its original author?</li>
  <li>✅ Is the license compatible with your project? (<code class="language-plaintext highlighter-rouge">license_finder</code> can confirm.)</li>
  <li>✅ Does <code class="language-plaintext highlighter-rouge">bundle-audit</code> come back clean, and does the team patch vulnerabilities quickly?</li>
  <li>✅ Does it follow semantic versioning with a readable <code class="language-plaintext highlighter-rouge">CHANGELOG</code>?</li>
  <li>✅ Is it the right size for the job, or are you renting a moving truck to carry one box?</li>
</ul>

<p>If a gem clears all seven, it is a much safer bet for the long term.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Adding a gem may feel like a shortcut today, but every gem becomes part of your app’s long-term health. By considering maintenance, community, licensing,
security, and fit, you can make smarter choices that reduce risks and avoid painful migrations later.</p>

<p>And if you don’t have time to review every gem, or if your app already relies on dozens of them, that’s where we can help.</p>

<p>Our <a href="https://www.fastruby.io/monthly-rails-maintenance">fixed-cost Rails maintenance service</a> keeps your dependencies updated, identifies risks before
they become roadblocks, and ensures your application stays secure, stable, and fit for the future.</p>

<p>Want peace of mind about your Rails app’s gem ecosystem? <a href="/#contactus">Let’s talk</a>.</p>]]></content><author><name>gelseyt</name></author><category term="best-practices" /><summary type="html"><![CDATA[How to decide whether to add a Ruby gem dependency or build it yourself: five factors to check for sustainable, maintainable, and secure Rails applications.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how-to-choose-a-gem-wisely.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/how-to-choose-a-gem-wisely.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sidekiq &amp;amp; Ruby Compatibility Table</title><link href="https://www.fastruby.io/blog/ruby/sidekiq/versions/compatibility-table.html" rel="alternate" type="text/html" title="Sidekiq &amp;amp; Ruby Compatibility Table" /><published>2026-05-28T06:10:03-04:00</published><updated>2026-05-28T06:10:03-04:00</updated><id>https://www.fastruby.io/blog/ruby/sidekiq/versions/sidekiq-ruby-compatibility</id><content type="html" xml:base="https://www.fastruby.io/blog/ruby/sidekiq/versions/compatibility-table.html"><![CDATA[<p>This is a short post to show the compatibility between <a href="https://sidekiq.org/">Sidekiq</a>
and <a href="https://www.ruby-lang.org/en/">Ruby</a> across different versions. In the
process of upgrading really old applications to more modern versions of Ruby and
Sidekiq we have run into a lot of these combinations.</p>

<!--more-->

<p>Sidekiq’s <a href="https://github.com/sidekiq/sidekiq/wiki/Commercial-Support#version-policy">maintenance policy</a>
only covers the current and previous major versions (8.x and 7.x today), as
long as they are less than five years old. Older versions (6.x and below) no
longer receive updates, including security fixes, so if you are on a legacy
version, upgrading is the safer path.</p>

<table id="sidekiq-ruby-compatibility-table" class="ruby-compatibility-table">
  <thead>
    <tr>
      <td><a href="https://rubygems.org/gems/sidekiq/versions" target="_blank">Sidekiq Version</a></td>
      <td><a href="https://www.ruby-lang.org/en/downloads/releases/" target="_blank">Required Ruby Version</a></td>
      <td>Recommended Ruby Version</td>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>8.1.Z</td>
      <td>&gt;= 3.2.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v8.1.6/.github/workflows/ci.yml#L23" target="_blank">4.0</a></td>
    </tr>
    <tr>
      <td>8.0.Z</td>
      <td>&gt;= 3.2.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v8.0.7/.github/workflows/ci.yml#L21" target="_blank">3.4</a></td>
    </tr>
    <tr>
      <td>7.3.Z</td>
      <td>&gt;= 2.7.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v7.3.9/.github/workflows/dragonfly.yml#L21" target="_blank">3.3</a></td>
    </tr>
    <tr>
      <td>6.5.Z</td>
      <td>&gt;= 2.5.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v6.5.12/.github/workflows/ci.yml#L20" target="_blank">3.1</a></td>
    </tr>
    <tr>
      <td>5.2.Z</td>
      <td>&gt;= 2.2.2</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v5.2.10/.travis.yml#L6-L10" target="_blank">2.6</a></td>
    </tr>
    <tr>
      <td>4.2.Z</td>
      <td>&gt;= 2.0.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v4.2.10/.travis.yml#L15" target="_blank">2.4</a></td>
    </tr>
    <tr>
      <td>3.5.Z</td>
      <td>&gt;= 2.0.0</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v3.5.4/.travis.yml#L6-L9" target="_blank">2.2</a></td>
    </tr>
    <tr>
      <td>2.17.Z</td>
      <td>&gt;= 1.9.3</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v2.17.8/.travis.yml#L4-L9" target="_blank">2.1</a></td>
    </tr>
    <tr>
      <td>1.2.Z</td>
      <td>&gt;= 1.9.3</td>
      <td><a href="https://github.com/sidekiq/sidekiq/blob/v1.2.1/README.md" target="_blank">1.9.3</a></td>
    </tr>
  </tbody>
</table>

<p>To find more information about the most recent Ruby releases check out this
page: <a href="https://www.ruby-lang.org/en/downloads/releases/">Ruby Releases</a></p>

<p>To check Sidekiq’s changes through releases you can check <a href="https://github.com/sidekiq/sidekiq/blob/main/Changes.md">Sidekiq changes</a> document in their GitHub repository.</p>

<p>Sidekiq’s versions maintenance <a href="https://github.com/sidekiq/sidekiq/wiki/Commercial-Support#version-policy">policy</a> is shared in their wiki where it says:</p>

<blockquote>
  <p>“Support the current major version and previous major version as long as they are less than five years old”</p>
</blockquote>

<h2 id="need-to-upgrade-sidekiq">Need to Upgrade Sidekiq?</h2>

<p>If you don’t have the time to do it yourself, you can hire our team to do it for you.
Send us a message over here: <a href="/#contactus">Contact FastRuby.io | Rails Upgrade Service</a></p>

<h2 id="feedback-wanted-updates">Feedback Wanted: Updates</h2>

<p>If you find that this article has fallen out of date, feel free to reach out
to us on <a href="https://ruby.social/@fastruby/">Mastodon</a> or
<a href="https://bsky.app/profile/fastruby.io">Bluesky</a> and we will bring it up to
speed. We will continue to update this article as new versions of Sidekiq and
Ruby are released.</p>]]></content><author><name>hmdros</name></author><category term="compatibility" /><summary type="html"><![CDATA[A complete table showing the compatibility between Sidekiq and Ruby across each version, check your upgrade options and know what's the latest version of Sidekiq or Ruby you can use.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/sidekiq-ruby-compatibility-table.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/sidekiq-ruby-compatibility-table.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Migrating a Rails App from Sprockets to JS Bundling with esbuild</title><link href="https://www.fastruby.io/blog/migrate-rails-app-from-sprockets-to-esbuild.html" rel="alternate" type="text/html" title="Migrating a Rails App from Sprockets to JS Bundling with esbuild" /><published>2026-05-08T07:13:30-04:00</published><updated>2026-05-08T07:13:30-04:00</updated><id>https://www.fastruby.io/blog/migrate-rails-app-from-sprockets-to-esbuild</id><content type="html" xml:base="https://www.fastruby.io/blog/migrate-rails-app-from-sprockets-to-esbuild.html"><![CDATA[<p>At FastRuby.io, we spend a lot of time upgrading Ruby and Rails applications. However, we do more than just that,
we also pay attention to other areas of the application that can be improved. For example, we recently migrated a
customer’s application from Sprockets to JS bundling with <a href="https://esbuild.github.io/">esbuild</a>.</p>

<p>In this article, I share my experience migrating from <a href="https://github.com/rails/sprockets">Sprockets</a> to
<a href="https://github.com/rails/jsbundling-rails">JS Bundling</a> (JavaScript Bundling for Rails).</p>

<p>This is not a step-by-step guide, as each application has its own unique needs. Instead, I discuss the problems
I encountered and the approach I took during the migration to JavaScript bundling.</p>

<!--more-->

<h2 id="why-we-needed-to-migrate">Why We Needed to Migrate</h2>

<p>Before we discuss why we needed to migrate, let me give you some insights into the state of the application. The application had <code class="language-plaintext highlighter-rouge">.js</code>
files, <code class="language-plaintext highlighter-rouge">.es6</code> files, <code class="language-plaintext highlighter-rouge">jquery</code>, <code class="language-plaintext highlighter-rouge">bootstrap.js</code>, and a few other 3rd party JS plugins loaded via CDNs.</p>

<p>The Gemfile also had <code class="language-plaintext highlighter-rouge">jquery-rails</code>, <code class="language-plaintext highlighter-rouge">coffee-rails</code>, <code class="language-plaintext highlighter-rouge">babel-transpiler</code>, <code class="language-plaintext highlighter-rouge">terser</code> gems in it. The application was using Sprockets for
asset management. All of these factors indicated that the JavaScript assets had not been updated in a while, and there was some confusion about the internal best practices for writing JavaScript:</p>

<ul>
  <li>“Should the code be in <code class="language-plaintext highlighter-rouge">.js</code> files?”</li>
  <li>“Should the code be in <code class="language-plaintext highlighter-rouge">.es6</code> files?”</li>
  <li>“Should they continue adding more inline JavaScript code?”</li>
</ul>

<p>Also, I knew, in the future, the client wanted to start using <a href="https://stimulus.hotwired.dev/">Stimulus</a>.</p>

<p>Considering the existing state of JS asset management and the future needs, the best course forward was to migrate from Sprockets to
JS bundling. JS bundling along with esbuild as the bundler supported all existing features that the application utilized, so the
developers did not face a huge learning curve, and it also gave access to features like tree shaking, instant reloads, and extremely
fast bundling, elevating the overall developer experience.</p>

<h2 id="planning-the-migration-strategy">Planning the Migration Strategy</h2>

<p>When we began planning our migration from Sprockets to <code class="language-plaintext highlighter-rouge">jsbundling-rails</code> + <code class="language-plaintext highlighter-rouge">esbuild</code>, we knew that migrating our substantial
JavaScript codebase across a production Rails application required careful orchestration.</p>

<p>The application presented unique challenges:</p>

<ul>
  <li>Numerous Stimulus-like controllers using <code class="language-plaintext highlighter-rouge">.es6</code> extensions</li>
  <li>A large <code class="language-plaintext highlighter-rouge">application.js</code> file filled with global functions</li>
  <li>Complex jQuery and Bootstrap 2 dependencies</li>
  <li>A Docker-based development environment that needed seamless build integration</li>
</ul>

<p>Rather than attempting a risky “big bang” rewrite, we developed a three-phase strategy that would allow us to validate
each step.</p>

<p>Our approach centered on creating parallel asset pipelines, keeping the existing Sprockets system running while building
and testing the new esbuild setup alongside it.</p>

<p>This meant we could migrate incrementally, test thoroughly at each phase. We also front-loaded the infrastructure work,
setting up our npm dependencies, Docker build processes, and development workflows before touching a single JavaScript
file.</p>

<h3 id="critical-distinction-testing-vs-merging-strategy">Critical Distinction: Testing vs Merging Strategy</h3>

<p>While we tested incrementally after every step, we were careful not to deploy partial work to production. Our strategy was:</p>

<ul>
  <li><strong>Test incrementally</strong>: Validate functionality after each small change (file migration, dependency addition, configuration update)</li>
  <li><strong>Commit to feature branch</strong>: We kept committing changes and merging to our base pull request throughout the process</li>
  <li><strong>Merge to main only at the end</strong>: The feature branch was merged to main only when the entire migration was complete and all cleanup was done</li>
</ul>

<p>This approach prevented <strong>deploying half-finished migrations to production</strong> while still allowing us to track progress and collaborate
on the feature branch. The parallel asset pipeline setup allowed continuous testing and incremental commits without risk of
breaking the production application.</p>

<p>The diagram below summarizes the six phases that follow, and how the parallel pipeline period tapered into an esbuild-only setup once the migration was complete.</p>

<p><img src="/blog/assets/images/sprockets-to-jsbundling-phases.png" alt="Six phases of the Sprockets to JS Bundling migration: Foundation Setup, File Migration, jQuery and Legacy, Docker and Workflow, Asset Cleanup, and Test and Validate. A bar at the bottom shows the parallel Sprockets and esbuild period tapering into an esbuild-only setup." width="1920" height="720" loading="lazy" decoding="async" /></p>

<h2 id="phase-1-foundation-setup">Phase 1: Foundation Setup</h2>

<p>The foundation phase focused on establishing the infrastructure needed for esbuild without touching existing JavaScript files.</p>

<p>Setting up the build infrastructure first meant we could test that <code class="language-plaintext highlighter-rouge">esbuild</code> actually worked in our specific environment including
Docker, Rails asset pipeline integration, and development workflow. If the build system didn’t work properly, we would find out immediately
rather than discovering fundamental infrastructure problems after we’d already migrated files.</p>

<h3 id="adding-jsbundling-rails-gem">Adding jsbundling-rails Gem</h3>

<p>First, we added <code class="language-plaintext highlighter-rouge">jsbundling-rails</code> to our Gemfile and generated the initial setup:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"jsbundling-rails"</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
./bin/rails javascript:install:esbuild
</code></pre></div></div>

<h3 id="creating-the-initial-packagejson-structure">Creating the Initial Package.json Structure</h3>

<p>We started with a minimal <code class="language-plaintext highlighter-rouge">package.json</code> and progressively added dependencies:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"app"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/*.* --bundle --sourcemap --format=iife --outdir=app/assets/builds --public-path=/assets"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="adding-esbuild-as-development-dependency">Adding esbuild as Development Dependency</h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"app"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"esbuild"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.25.6"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/*.* --bundle --sourcemap --format=iife --outdir=app/assets/builds --public-path=/assets"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Front-loading All JavaScript Dependencies</strong></p>

<p>Rather than adding dependencies incrementally, we added all required packages upfront to avoid mid-migration dependency issues. Here’s how we determined which dependencies to add to our package.json:</p>

<p><strong>1. Audit Your Layout Files for CDN Links</strong>
Look for <code class="language-plaintext highlighter-rouge">&lt;script src="https://..."&gt;</code> tags to identify externally loaded libraries:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Found in app/views/layouts/application.html.erb --&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://code.jquery.com/jquery-3.7.1.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/flatpickr"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p><strong>2. Check Your Sprockets Manifests</strong>
Review <code class="language-plaintext highlighter-rouge">app/assets/javascripts/application.js</code> for <code class="language-plaintext highlighter-rouge">//= require</code> statements:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">//= require jquery</span>
<span class="c1">//= require bootstrap</span>
<span class="c1">//= require flatpickr</span>
</code></pre></div></div>

<p><strong>3. Search Your Codebase for Global Variable Usage</strong>
Use grep to find library-specific globals:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-r</span> <span class="s2">"jQuery</span><span class="se">\|\\</span><span class="nv">$\</span><span class="se">\.</span><span class="s2">"</span> app/  <span class="c"># Find jQuery usage</span>
<span class="nb">grep</span> <span class="nt">-r</span> <span class="s2">"flatpickr</span><span class="se">\|</span><span class="s2">bootstrap"</span> app/  <span class="c"># Find other library usage</span>
</code></pre></div></div>

<p><strong>4. Review Your Ruby Gems</strong>
Check your Gemfile for <code class="language-plaintext highlighter-rouge">-rails</code> gems that wrap JS libraries:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"jquery-rails"</span>        <span class="c1"># Indicates jQuery dependency</span>
<span class="n">gem</span> <span class="s2">"bootstrap-sass"</span>      <span class="c1"># Indicates Bootstrap dependency</span>
</code></pre></div></div>

<p><strong>5. Version Matching Strategy</strong>
Match existing CDN/gem versions exactly first, then upgrade as a separate step after migration works.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"app"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"jquery"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.7.1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"bootstrap"</span><span class="p">:</span><span class="w"> </span><span class="s2">"4.4.1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"popper.js"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.16.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"flatpickr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.6.13"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"bootstrap-select"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.13.18"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"esbuild"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.25.6"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/application-esbuild.js --bundle --sourcemap --format=iife --outfile=app/assets/builds/application-bundled.js"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Key Decision</strong>: We chose specific versions that matched our existing CDN dependencies to ensure compatibility.</p>

<p>With npm packages installed, we could remove CDN links from our layout files:</p>

<p><strong>Before (CDN approach):</strong></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb --&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://code.jquery.com/jquery-3.7.1.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p><strong>After (esbuild approach):</strong></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Dependencies now bundled in application.js --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span><span class="p">,</span> <span class="s2">"data-turbo-track"</span><span class="p">:</span> <span class="s2">"reload"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>This eliminated external dependencies and improved performance by reducing HTTP requests and enabling better caching control.</p>

<p><strong>Setting Up the Entry Point</strong></p>

<p>Created the initial JavaScript entry point that would eventually replace our Sprockets bundle:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application-esbuild.js</span>
<span class="c1">// Entry point for the build script in your package.json</span>

<span class="c1">// Import jQuery and make it available globally</span>
<span class="k">import</span> <span class="nx">$</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">jquery</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">jQuery</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">$</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>

<span class="c1">// Import Popper.js (required by Bootstrap 4)</span>
<span class="k">import</span> <span class="nx">Popper</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">popper.js</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">Popper</span> <span class="o">=</span> <span class="nx">Popper</span><span class="p">;</span>

<span class="c1">// Import Bootstrap</span>
<span class="k">import</span> <span class="dl">"</span><span class="s2">bootstrap</span><span class="dl">"</span><span class="p">;</span>

<span class="c1">// Import Flatpickr</span>
<span class="k">import</span> <span class="nx">flatpickr</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">flatpickr</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">flatpickr</span> <span class="o">=</span> <span class="nx">flatpickr</span><span class="p">;</span>

<span class="c1">// Import bootstrap-select</span>
<span class="k">import</span> <span class="dl">"</span><span class="s2">bootstrap-select</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Critical Decision</strong>: We maintained global variable assignments (<code class="language-plaintext highlighter-rouge">window.$ = $</code>) to ensure compatibility with existing legacy code that expected these globals.</p>

<p><strong>Configuring the Asset Pipeline Integration</strong></p>

<p>Updated the Sprockets manifest to include esbuild output:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/assets/config/manifest.js</span>
<span class="c1">//= link_tree ../images</span>
<span class="c1">//= link application-bootstrap4.css</span>
<span class="c1">//= link_directory ../stylesheets .css</span>
<span class="c1">//= link bootstrap-select.min.js</span>
<span class="c1">//= link_tree ../builds  // Added this line</span>
</code></pre></div></div>

<p><strong>Key Integration Point</strong>: The <code class="language-plaintext highlighter-rouge">//= link_tree ../builds</code> line is crucial because it tells Sprockets to include all files from the <code class="language-plaintext highlighter-rouge">app/assets/builds/</code> directory in the asset pipeline. This is where esbuild outputs the bundled JavaScript file (as configured in the package.json <code class="language-plaintext highlighter-rouge">--outfile=app/assets/builds/application-bundled.js</code>). Without this line, Rails wouldn’t know about the esbuild-generated JavaScript and couldn’t serve it to browsers.</p>

<p><strong>Setting Up Parallel Asset Pipeline</strong></p>

<p>We added JavaScript includes to layouts for testing while keeping the existing Sprockets system running. “Keeping Sprockets” meant two things: first, Sprockets continued to handle all non-JavaScript assets (CSS, images, fonts) and serve as the overall asset pipeline coordinator; second, the old Sprockets-managed JavaScript bundle (<code class="language-plaintext highlighter-rouge">application.js</code>) remained functional alongside the new esbuild-managed bundle (<code class="language-plaintext highlighter-rouge">application-bundled.js</code>), allowing us to test by switching between them:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span><span class="p">,</span> <span class="s2">"data-turbo-track"</span><span class="p">:</span> <span class="s2">"reload"</span><span class="p">,</span> <span class="ss">type: </span><span class="s2">"module"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><strong>Docker Integration for Development</strong></p>

<p>Updated Dockerfile to handle npm dependencies:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Node.js and npm first (before copying files)</span>
<span class="k">RUN </span>curl <span class="nt">-fsSL</span> https://deb.nodesource.com/setup_18.x | bash - <span class="o">&amp;&amp;</span> <span class="se">\
</span>    apt-get <span class="nb">install</span> <span class="nt">-y</span> nodejs
<span class="k">RUN </span>node <span class="nt">-v</span> <span class="o">&amp;&amp;</span> npm <span class="nt">-v</span>

<span class="c"># Copy dependency files first for better Docker caching</span>
<span class="k">COPY</span><span class="s"> Gemfile* /code/</span>
<span class="k">COPY</span><span class="s"> package*.json /code/</span>

<span class="c"># Install Ruby dependencies</span>
<span class="k">RUN </span>bundle <span class="nb">install</span>

<span class="c"># Install JavaScript dependencies</span>
<span class="k">RUN </span>npm <span class="nb">install</span>

<span class="c"># Copy all the application's files into the /code directory</span>
<span class="k">COPY</span><span class="s"> . /code</span>

<span class="c"># Build JavaScript assets</span>
<span class="k">RUN </span>npm run build
</code></pre></div></div>

<p>Updated development startup scripts to include JavaScript asset building alongside Rails server startup.</p>

<p><strong>Development Process Setup</strong></p>

<p>Created Procfile.dev for concurrent Rails and esbuild processes:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Procfile.dev</span>
<span class="na">web</span><span class="pi">:</span> <span class="s">env RUBY_DEBUG_OPEN=true bin/rails server</span>
<span class="na">js</span><span class="pi">:</span> <span class="s">npm run build -- --watch</span>
</code></pre></div></div>

<h2 id="phase-2-the-great-file-migration">Phase 2: The Great File Migration</h2>

<p>Once our foundation was solid, we began the systematic migration of JavaScript files from the Sprockets world to the esbuild world. This phase was broken into various sub-phases to manage complexity and validate functionality at each step.</p>

<p><strong>Phase 2a: Basic Utility Files</strong></p>

<p>We started with simple, standalone files that had minimal dependencies:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Files migrated in Phase 2a:</span>
<span class="c1">// app/javascript/extensions.js</span>
<span class="c1">// app/javascript/safari_datepicker_fix.js</span>
<span class="c1">// app/javascript/chrome_datapicker_fix.js</span>
</code></pre></div></div>

<p><strong>Key Learning</strong>: These files required no changes beyond moving location - they were already using modern JavaScript patterns.</p>

<p><strong>Phase 2b: Application Logic and Business Rules</strong></p>

<p>Next, we moved files containing business logic and form handling:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Files migrated in Phase 2b:</span>
<span class="c1">// app/javascript/file_upload.js</span>
<span class="c1">// app/javascript/user_form.js</span>
</code></pre></div></div>

<p><strong>Example Migration - users.js:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Previously loaded via Sprockets directive: //= require users</span>
<span class="c1">// Now imported as ES6 module in app/javascript/application.js:</span>
<span class="c1">// import('./users.js');</span>

<span class="kd">let</span> <span class="nx">setupUserEvents</span> <span class="o">=</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
  <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">a[data-delete-unpaid-user]</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">(</span><span class="nf">function </span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">a[data-delete-unpaid-user-confirm]</span><span class="dl">"</span><span class="p">).</span><span class="nf">attr</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">,</span> <span class="nf">$</span><span class="p">(</span><span class="k">this</span><span class="p">).</span><span class="nf">attr</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">));</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#delete_unpaid_associate_modal</span><span class="dl">"</span><span class="p">).</span><span class="nf">modal</span><span class="p">(</span><span class="dl">"</span><span class="s2">show</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
  <span class="p">});</span>

  <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[data-user-role-toggle]</span><span class="dl">"</span><span class="p">).</span><span class="nf">change</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">action</span> <span class="o">=</span> <span class="nf">$</span><span class="p">(</span><span class="k">this</span><span class="p">).</span><span class="nf">prop</span><span class="p">(</span><span class="dl">"</span><span class="s2">checked</span><span class="dl">"</span><span class="p">)</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">add</span><span class="dl">"</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">remove</span><span class="dl">"</span><span class="p">;</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#confirm_role_change_action</span><span class="dl">"</span><span class="p">).</span><span class="nf">text</span><span class="p">(</span><span class="nx">action</span><span class="p">);</span>
    <span class="c1">// ... rest of function logic</span>
  <span class="p">});</span>
<span class="p">};</span>

<span class="c1">// Make globally accessible for .js.erb files</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">setupUserEvents</span> <span class="o">=</span> <span class="nx">setupUserEvents</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Phase 2c: Complex Business Forms</strong></p>

<p>The most complex files were migrated last:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Files migrated in Phase 2c:</span>
<span class="c1">// app/javascript/user_worklist.js</span>
<span class="c1">// app/javascript/user_services_form.js</span>
</code></pre></div></div>

<p>Some of the reasons that made these files particularly complex were:</p>

<ul>
  <li>Multiple AJAX interactions - Both files heavily used <code class="language-plaintext highlighter-rouge">$.ajax()</code> calls for dynamic form submissions without page refreshes</li>
  <li>Complex DOM manipulation - Extensive jQuery selectors and event handlers for form interactions</li>
  <li>State management - Managing form state, pagination, sorting, and filtering logic</li>
  <li>Global function dependencies - Required multiple functions to be globally accessible for Rails <code class="language-plaintext highlighter-rouge">.js.erb</code> integration</li>
</ul>

<p><strong>Phase 2d: The ES6 Controller Migration</strong></p>

<p>This was the largest single migration step - moving all Stimulus-style controllers:</p>

<p><strong>Before (.es6 files in app/assets/javascripts):</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/assets/javascripts/controllers/
├── aria_checked_controller.es6
└── hr/pagination_controller.es6
</code></pre></div></div>

<p><strong>After (.js files in app/javascript):</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/javascript/controllers/
├── aria_checked_controller.js
└── hr/pagination_controller.js
</code></pre></div></div>

<p><strong>Simple File Extension Migration:</strong></p>

<p>A major advantage in our case was that all our <code class="language-plaintext highlighter-rouge">.es6</code> files contained vanilla JavaScript that was already compatible with modern browsers. This meant we could simply rename the files from <code class="language-plaintext highlighter-rouge">.es6</code> to <code class="language-plaintext highlighter-rouge">.js</code> without any code changes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Our migration was this simple:</span>
<span class="nb">mv </span>aria_checked_controller.es6 aria_checked_controller.js
<span class="nb">mv </span>hr/pagination_controller.es6 hr/pagination_controller.js
<span class="c"># ... and so on</span>
</code></pre></div></div>

<p><strong>Example Controller - No Code Changes Needed:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// FROM: app/assets/javascripts/controllers/aria_checked_controller.es6</span>
<span class="c1">// TO: app/javascript/controllers/aria_checked_controller.js</span>
<span class="c1">// (Content identical - just file extension and location changed)</span>

<span class="kd">class</span> <span class="nc">AriaCheckedController</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">(</span><span class="nx">fieldset</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">fieldset</span> <span class="o">=</span> <span class="nx">fieldset</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">connect</span><span class="p">();</span>
  <span class="p">}</span>

  <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">updateState</span><span class="p">();</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">listen</span><span class="p">();</span>
  <span class="p">}</span>

  <span class="nf">listen</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">listenToInputs</span><span class="p">();</span>
  <span class="p">}</span>

  <span class="nf">listenToInputs</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">inputs</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">input</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">input</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">change</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">updateState</span><span class="p">());</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="nf">updateState</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">radioLabels</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">label</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="nx">label</span><span class="p">.</span><span class="nx">ariaChecked</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">false</span><span class="dl">"</span><span class="p">));</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">checkedLabels</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">label</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="nx">label</span><span class="p">.</span><span class="nx">ariaChecked</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">));</span>
  <span class="p">}</span>

  <span class="c1">// ... rest of controller</span>
<span class="p">}</span>

<span class="c1">// Export for global access (needed for Rails .js.erb files)</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">AriaCheckedController</span> <span class="o">=</span> <span class="nx">AriaCheckedController</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Our Lucky Break:</strong></p>

<p>Since our <code class="language-plaintext highlighter-rouge">.es6</code> files were essentially vanilla JavaScript with ES6 classes and modern syntax that browsers already supported, the migration was purely mechanical - just moving files and updating import statements.</p>

<p><strong>If Code Changes Were Needed:</strong></p>

<p>Had our ES6 code contained incompatible syntax or used ES6 modules extensively like the example shown below:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// BEFORE (.es6 file with ES6 modules):</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SomeUtility</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./utilities</span><span class="dl">"</span><span class="p">;</span> <span class="c1">// ← ES6 import syntax</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">MyController</span> <span class="p">{</span>
  <span class="c1">// ← ES6 export syntax</span>
  <span class="c1">// ES6 syntax that needs transpilation</span>
  <span class="k">async</span> <span class="nf">handleClick</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/data</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">result</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
    <span class="c1">// ...</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>We would have needed to implement a transpilation solution using either:</p>

<ul>
  <li><strong>esbuild’s built-in transpilation</strong> with appropriate target settings (<code class="language-plaintext highlighter-rouge">--target=es2017</code>)</li>
  <li><strong>Babel integration</strong> for more complex transformations via plugins like <code class="language-plaintext highlighter-rouge">esbuild-plugin-babel</code></li>
</ul>

<p>This would have added complexity to the build process but kept our code more modern and maintainable.</p>

<h3 id="managing-import-dependencies-in-application-esbuildjs">Managing Import Dependencies in application-esbuild.js</h3>

<p>As we migrated files, we updated our entry point to import them:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application-esbuild.js progression</span>

<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./extensions.js</span><span class="dl">"</span><span class="p">);</span>
<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./safari_datepicker_fix.js</span><span class="dl">"</span><span class="p">);</span>
<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./chrome_datepicker_fix.js</span><span class="dl">"</span><span class="p">);</span>

<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./aria_checked_controller.js</span><span class="dl">"</span><span class="p">);</span>
<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./hr/pagination_controller.es6</span><span class="dl">"</span><span class="p">);</span>
<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./user_worklist.js</span><span class="dl">"</span><span class="p">);</span>
<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./user_services_form.js</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="critical-pattern-global-function-accessibility">Critical Pattern: Global Function Accessibility</h3>

<p>A key challenge was maintaining global function access for Rails <code class="language-plaintext highlighter-rouge">.js.erb</code> files:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Pattern we used throughout migration:</span>

<span class="c1">// Modern ES6 approach (preferred):</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">setupuserTableEvents</span><span class="p">()</span> <span class="p">{</span>
  <span class="cm">/* ... */</span>
<span class="p">}</span>

<span class="c1">// But also global assignment (necessary for Rails):</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">setupuserTableEvents</span> <span class="o">=</span> <span class="nx">setupuserTableEvents</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Why This Was Necessary:</strong>
Rails <code class="language-plaintext highlighter-rouge">.js.erb</code> files expect functions to be globally accessible:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/users/edit.js.erb --&gt;</span>
$('#users_table').replaceWith('<span class="cp">&lt;%=</span> <span class="n">j</span> <span class="n">render</span><span class="p">(</span><span class="ss">partial: </span><span class="s2">"users/users_table"</span><span class="p">)</span> <span class="cp">%&gt;</span>')
setupuserTableEvents()  <span class="c">&lt;!-- This expects global function --&gt;</span>
</code></pre></div></div>

<h3 id="module-wise-testing-strategy">Module-wise Testing Strategy</h3>

<p>While we followed the file migration phases described above, we also implemented a <strong>module-wise testing approach</strong> to validate functionality incrementally. This required creating multiple temporary JavaScript bundles so we could test incremental changes by moving only specific parts of the app to jsbundling, testing that, while the rest of the app continued being served by the old asset pipeline:</p>

<ol>
  <li><strong>application.js</strong> - Original Sprockets bundle (unchanged during migration)</li>
  <li><strong>application-bundled.js</strong> - Initial esbuild output file containing new dependencies like jQuery, Bootstrap (later renamed to application.js)</li>
  <li><strong>application-modern.js</strong> - Temporary Sprockets bundle for testing during migration (later removed)</li>
</ol>

<p><strong>Creating the Hybrid JavaScript Bundle (application-modern.js):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/assets/javascripts/application-modern.js</span>
<span class="c1">// This is a manifest file for the modern JavaScript setup with esbuild</span>
<span class="c1">// It excludes jQuery since that's loaded from the bundled file</span>
<span class="c1">//</span>
<span class="c1">//= require users</span>
<span class="c1">//= require extensions</span>
<span class="c1">//= require safari_datepicker_fix</span>
<span class="c1">//= require_tree ./controllers</span>
<span class="c1">//= require_tree ./helpers</span>
<span class="c1">//= require_tree ./services</span>
<span class="c1">//= require_tree ./timecards</span>
<span class="c1">//= require_tree ./vsr</span>

<span class="c1">// ... rest of application logic</span>
</code></pre></div></div>

<p><strong>Layout-level Testing:</strong>
We could then test specific modules by modifying which JavaScript bundle the layouts loaded:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb --&gt;</span>
<span class="c">&lt;!-- BEFORE: Original Sprockets + CDN approach --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://code.jquery.com/jquery-3.5.1.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>

<span class="c">&lt;!-- AFTER: Modern esbuild + Sprockets hybrid approach --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-bundled"</span> <span class="cp">%&gt;</span>  <span class="c">&lt;!-- esbuild dependencies --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-modern"</span> <span class="cp">%&gt;</span>   <span class="c">&lt;!-- core app logic --&gt;</span>
</code></pre></div></div>

<p><strong>Layout-specific Testing:</strong>
Different layouts could use different JavaScript bundles:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Test User module: app/views/layouts/user.html.erb --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-bundled"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-modern"</span> <span class="cp">%&gt;</span>

<span class="c">&lt;!-- Keep main app on old system: app/views/layouts/application.html.erb --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span> <span class="cp">%&gt;</span>
<span class="c">&lt;!-- CDN scripts... --&gt;</span>
</code></pre></div></div>

<p><strong>Benefits of Module-wise Testing:</strong></p>

<ul>
  <li><strong>Risk Mitigation</strong>: Issues isolated to specific functionality areas</li>
  <li><strong>Faster Debugging</strong>: Easier to identify which JavaScript caused issues</li>
  <li><strong>Stakeholder Confidence</strong>: Could demonstrate working functionality module by module</li>
  <li><strong>Incremental Validation</strong>: Each module tested thoroughly before moving to next</li>
</ul>

<p>This approach was particularly valuable for our client since different modules had different user bases and usage patterns.</p>

<p><strong>Results of Phase 2</strong></p>

<p>After this phase, we had:</p>

<ul>
  <li>JavaScript files migrated to esbuild</li>
  <li>All <code class="language-plaintext highlighter-rouge">.es6</code> extensions converted to <code class="language-plaintext highlighter-rouge">.js</code></li>
  <li>Global function access preserved for Rails integration</li>
  <li>Organized file structure (<code class="language-plaintext highlighter-rouge">controllers/</code>, <code class="language-plaintext highlighter-rouge">services/</code>, <code class="language-plaintext highlighter-rouge">helpers/</code>)</li>
  <li>Module-wise testing capability through parallel layouts</li>
  <li>No breaking changes to existing functionality</li>
</ul>

<h2 id="phase-3-solving-jquery-and-legacy-dependencies">Phase 3: Solving jQuery and Legacy Dependencies</h2>

<p>Phase 3 addressed the most challenging aspect of the migration: ensuring jQuery and other dependencies were available when legacy code expected them, while managing multiple Bootstrap versions across different layouts.</p>

<p><strong>The jquery-loader.js Solution</strong></p>

<p>Our biggest challenge was timing - ensuring jQuery was globally available before other scripts tried to use it.</p>

<p>We initially tried a couple of different approaches:
<strong>Before (problematic timing):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application.js - BROKEN approach</span>
<span class="k">import</span> <span class="nx">$</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">jquery</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">jQuery</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">$</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>

<span class="c1">// These imports might execute before $ is globally available</span>
<span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./users.js</span><span class="dl">"</span><span class="p">);</span> <span class="c1">// Might fail if $ not ready</span>
  <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./controllers/nav_tabs_controller.js</span><span class="dl">"</span><span class="p">);</span>
<span class="p">},</span> <span class="mi">0</span><span class="p">);</span>
</code></pre></div></div>

<p>Both approaches had problems. We solved those with a custom loader:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/jquery-loader.js</span>
<span class="k">import</span> <span class="nx">$</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">jquery</span><span class="dl">"</span><span class="p">;</span>

<span class="c1">// Attach to window for legacy plugins</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">jQuery</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">$</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>

<span class="c1">// Resolved promise to guarantee jQuery is globally available before dependent code executes</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">jqueryReady</span> <span class="o">=</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">resolve</span><span class="p">(</span><span class="nx">$</span><span class="p">);</span>

<span class="cm">/**
 * Helper function to ensure jQuery is ready before executing code.
 * @param {Function} callback - An async or sync function that receives the $ object.
 * @returns {Promise&lt;void&gt;} A promise that resolves when the callback is done.
 */</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">withJquery</span><span class="p">(</span><span class="nx">callback</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">jq</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">jqueryReady</span><span class="p">;</span>
  <span class="k">return</span> <span class="nf">callback</span><span class="p">(</span><span class="nx">jq</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>After (guaranteed timing):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application.js - WORKING approach</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">withJquery</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./jquery-loader.js</span><span class="dl">"</span><span class="p">;</span>

<span class="nf">withJquery</span><span class="p">(</span><span class="k">async </span><span class="p">(</span><span class="nx">$</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// All imports happen after jQuery is confirmed available</span>
  <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./users.js</span><span class="dl">"</span><span class="p">);</span>
  <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./nav_tabs_controller.js</span><span class="dl">"</span><span class="p">);</span>
  <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./pagination_controller.js</span><span class="dl">"</span><span class="p">);</span>
  <span class="c1">// ... all other imports</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Switching from Rails UJS to jQuery UJS</strong></p>

<p>We were using the <code class="language-plaintext highlighter-rouge">jquery-rails</code> gem, which made our AJAX requests work. However, as we were integrating JS bundling, we took it as an opportunity to get rid of the gem and take a step further to drop <code class="language-plaintext highlighter-rouge">jquery-ujs</code> altogether and replace it with <code class="language-plaintext highlighter-rouge">rails/ujs</code>.</p>

<p>As we integrated <code class="language-plaintext highlighter-rouge">rails/ujs</code>, we discovered compatibility issues with it and switched back to the jQuery-based version:</p>

<p><strong>Before (@rails/ujs - Complex event handling):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Complex event handling for @rails/ujs</span>
<span class="nf">$</span><span class="p">(</span><span class="nb">document</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:before</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">#add_line_item</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">var</span> <span class="nx">$link</span> <span class="o">=</span> <span class="nf">$</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
  <span class="kd">var</span> <span class="nx">baseUrl</span> <span class="o">=</span> <span class="nx">$link</span><span class="p">.</span><span class="nf">attr</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">).</span><span class="nf">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">&amp;count=</span><span class="dl">"</span><span class="p">)[</span><span class="mi">0</span><span class="p">];</span>
  <span class="nx">$link</span><span class="p">.</span><span class="nf">attr</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">,</span> <span class="nx">baseUrl</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">&amp;count=</span><span class="dl">"</span> <span class="o">+</span> <span class="o">++</span><span class="nb">window</span><span class="p">.</span><span class="nx">line_item_count</span><span class="p">);</span>
<span class="p">});</span>

<span class="nf">$</span><span class="p">(</span><span class="nb">document</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:success</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">#add_line_item</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// In @rails/ujs, the response is in e.detail[0]</span>
  <span class="kd">var</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">detail</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
  <span class="c1">// Complex response parsing logic...</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>After (jquery-ujs - Simple and familiar):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Much simpler with jquery-ujs</span>
<span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#add_line_item</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:beforeSend</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">xhr</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">status</span><span class="p">.</span><span class="nx">url</span> <span class="o">=</span> <span class="nx">status</span><span class="p">.</span><span class="nx">url</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">&amp;count=</span><span class="dl">"</span> <span class="o">+</span> <span class="o">++</span><span class="nb">window</span><span class="p">.</span><span class="nx">line_item_count</span><span class="p">;</span>
  <span class="p">})</span>
  <span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:success</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">xhr</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#line_items tbody</span><span class="dl">"</span><span class="p">).</span><span class="nf">append</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">);</span>
  <span class="p">})</span>
  <span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:error</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">xhr</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#line_items tbody</span><span class="dl">"</span><span class="p">).</span><span class="nf">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">&lt;p&gt;ERROR&lt;/p&gt;</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">});</span>
</code></pre></div></div>

<p><strong>Package change:</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@rails/ujs"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^7.1.3-4"</span><span class="p">,</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="err">←</span><span class="w"> </span><span class="err">Removed</span><span class="w">
    </span><span class="nl">"jquery-ujs"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.2.3"</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="err">←</span><span class="w"> </span><span class="err">Added</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The reason we did not invest too much effort into making <code class="language-plaintext highlighter-rouge">rails/ujs</code> work with our existing forms was that we wanted to remain focused on migrating to JS bundling, and rewriting our forms to work with <code class="language-plaintext highlighter-rouge">rails/ujs</code> seemed like a big enough deviation from our original goal.</p>

<p><strong>Bootstrap Version Detection and Conditional Loading</strong></p>

<p>Our application used different Bootstrap versions across layouts, requiring conditional loading:</p>

<p><strong>Layout Detection Strategy:</strong></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb (Bootstrap 2) --&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span> <span class="na">data-bootstrap-version=</span><span class="s">"2"</span><span class="nt">&gt;</span>

<span class="c">&lt;!-- app/views/layouts/application_bootstrap4.html.erb (Bootstrap 4) --&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span> <span class="na">data-bootstrap-version=</span><span class="s">"4"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p><strong>Conditional Bootstrap Loading:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application.js</span>
<span class="c1">// Conditionally import Bootstrap based on layout</span>
<span class="k">if </span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">data-bootstrap-version</span><span class="dl">"</span><span class="p">)</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">4</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// Import Bootstrap 4 for modern layouts</span>
  <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">bootstrap</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
  <span class="c1">// Import custom Bootstrap 2 modal for legacy layouts</span>
  <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">bootstrap2</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Bootstrap Version Compatibility</strong></p>

<p>The conditional loading handled different Bootstrap versions across layouts without requiring separate modal implementations.</p>

<p><strong>Managing Vendor Library Integrations</strong></p>

<p><strong>Flatpickr jQuery Plugin:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application.js</span>
<span class="k">import</span> <span class="nx">flatpickr</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">flatpickr</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">flatpickr</span> <span class="o">=</span> <span class="nx">flatpickr</span><span class="p">;</span>

<span class="c1">// Add flatpickr as a jQuery plugin for legacy code compatibility</span>
<span class="nx">$</span><span class="p">.</span><span class="nx">fn</span><span class="p">.</span><span class="nx">flatpickr</span> <span class="o">=</span> <span class="nf">function </span><span class="p">(</span><span class="nx">config</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
    <span class="nf">flatpickr</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="nx">config</span><span class="p">);</span>
  <span class="p">});</span>
<span class="p">};</span>
</code></pre></div></div>

<p><strong>Global Function Pattern for Rails Integration</strong></p>

<p>Many functions needed global access for <code class="language-plaintext highlighter-rouge">.js.erb</code> files:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Pattern used throughout the application</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">capturePaginationAndSortLinks</span> <span class="o">=</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
  <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">a.page-link</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">(</span><span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">e</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
    <span class="kd">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="nf">$</span><span class="p">(</span><span class="k">this</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">attr</span><span class="p">(</span><span class="dl">"</span><span class="s2">href</span><span class="dl">"</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/page=</span><span class="se">(\d</span><span class="sr">*</span><span class="se">)</span><span class="sr">/</span><span class="p">)[</span><span class="mi">1</span><span class="p">];</span>
    <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">input[data-page-field]</span><span class="dl">"</span><span class="p">).</span><span class="nf">val</span><span class="p">(</span><span class="nx">page</span><span class="p">);</span>
    <span class="nf">submitForm</span><span class="p">();</span>
  <span class="p">});</span>
<span class="p">};</span>

<span class="nb">window</span><span class="p">.</span><span class="nx">clearSupervisor</span> <span class="o">=</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
  <span class="c1">// Clear supervisor data logic</span>
<span class="p">};</span>

<span class="c1">// Multiple other global functions...</span>
</code></pre></div></div>

<p><strong>Results of Phase 3</strong></p>

<p>After this phase, we had:</p>

<ul>
  <li>Reliable jQuery loading with proper timing</li>
  <li>Simplified AJAX handling with jquery-ujs</li>
  <li>Multi-Bootstrap version support</li>
  <li>All vendor libraries properly integrated</li>
  <li>Global function access maintained for Rails</li>
  <li>No breaking changes to existing jQuery-dependent code</li>
</ul>

<p><strong>Key Insight</strong>: The jquery-loader pattern proved essential for any Rails application with significant jQuery dependencies, ensuring consistent timing and global availability.</p>

<h2 id="phase-4-docker-and-development-workflow-changes">Phase 4: Docker and Development Workflow Changes</h2>

<p>The migration to esbuild required significant changes to our Docker-based development environment and build processes to support both npm dependencies and JavaScript bundling.</p>

<p><strong>Key Additions:</strong></p>

<ol>
  <li><strong>Node.js installation</strong> - Added official Node.js APT repository</li>
  <li><strong>Package.json copying</strong> - Copy npm dependency files for caching</li>
  <li><strong>JavaScript dependencies</strong> - Install npm packages</li>
  <li><strong>Asset building</strong> - Build JavaScript assets during image creation</li>
</ol>

<h3 id="development-server-integration">Development Server Integration</h3>

<p>We added JavaScript asset management to our development startup script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ensure npm packages are installed (in case of volume mount)</span>
<span class="nb">echo</span> <span class="s2">"Installing npm packages..."</span>
npm <span class="nb">install</span>

<span class="c"># Clean precompiled assets to ensure fresh builds are served</span>
<span class="nb">echo</span> <span class="s2">"Cleaning precompiled assets..."</span>
bundle <span class="nb">exec </span>rails assets:clobber

<span class="c"># Build JavaScript assets in background</span>
<span class="nb">echo</span> <span class="s2">"Starting JavaScript build watcher..."</span>
npm run build:watch &amp;
</code></pre></div></div>

<p><strong>Why These Additions Were Necessary:</strong></p>

<ol>
  <li><strong>Volume mounts</strong> - In Docker development, <code class="language-plaintext highlighter-rouge">node_modules</code> might not persist, requiring <code class="language-plaintext highlighter-rouge">npm install</code> on startup</li>
  <li><strong>Asset conflicts</strong> - Rails asset clobber prevents conflicts between Sprockets and esbuild outputs</li>
  <li><strong>Hot reloading</strong> - Background <code class="language-plaintext highlighter-rouge">build:watch</code> enables instant JavaScript updates during development</li>
</ol>

<h3 id="build-script-configuration">Build Script Configuration</h3>

<p>Created separate build configurations for development and production:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build:watch"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/application.js --bundle --sourcemap --format=iife --outfile=app/assets/builds/application.js --watch=forever"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/application.js --bundle --sourcemap --format=iife --outfile=app/assets/builds/application.js --minify"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Development vs Production Differences:</strong></p>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Development (<code class="language-plaintext highlighter-rouge">build:watch</code>)</th>
      <th>Production (<code class="language-plaintext highlighter-rouge">build</code>)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Watch mode</strong></td>
      <td><code class="language-plaintext highlighter-rouge">--watch=forever</code></td>
      <td>No watching</td>
    </tr>
    <tr>
      <td><strong>Minification</strong></td>
      <td>No minification</td>
      <td><code class="language-plaintext highlighter-rouge">--minify</code></td>
    </tr>
    <tr>
      <td><strong>Source maps</strong></td>
      <td><code class="language-plaintext highlighter-rouge">--sourcemap</code></td>
      <td><code class="language-plaintext highlighter-rouge">--sourcemap</code></td>
    </tr>
    <tr>
      <td><strong>Purpose</strong></td>
      <td>Hot reloading during development</td>
      <td>Optimized production bundle</td>
    </tr>
  </tbody>
</table>

<p> </p>
<h3 id="docker-compose-integration">Docker Compose Integration</h3>

<p>Our docker-compose.yml worked seamlessly with the JavaScript build integration - no changes were needed to the volume configuration since the npm dependencies and builds happen inside the container.</p>

<h3 id="development-workflow-impact">Development Workflow Impact</h3>

<p><strong>Before esbuild:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Simple workflow</span>
docker-compose up
<span class="c"># Rails server starts immediately</span>
</code></pre></div></div>

<p><strong>After esbuild:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Enhanced workflow</span>
docker-compose up
<span class="c"># Container runs: npm install → build:watch → rails server</span>
<span class="c"># JavaScript changes automatically trigger esbuild rebuilds</span>
<span class="c"># Hot reloading works seamlessly</span>
</code></pre></div></div>

<h3 id="asset-serving-strategy">Asset Serving Strategy</h3>

<p><strong>Development:</strong></p>

<ul>
  <li>esbuild outputs to <code class="language-plaintext highlighter-rouge">app/assets/builds/application.js</code></li>
  <li>Rails serves via standard asset pipeline</li>
  <li>Changes trigger immediate rebuilds</li>
</ul>

<p><strong>Production:</strong></p>

<ul>
  <li>Assets precompiled during Docker build</li>
  <li>Minified bundles served with fingerprinting</li>
  <li>No runtime JavaScript compilation needed</li>
</ul>

<h3 id="results-of-docker-integration">Results of Docker Integration</h3>

<p>After these changes, we achieved:</p>

<ul>
  <li><strong>Seamless development experience</strong> - No manual JavaScript building required</li>
  <li><strong>Hot reloading</strong> - JavaScript changes reflected immediately in browser</li>
  <li><strong>Production readiness</strong> - Minified assets built during deployment</li>
  <li><strong>Developer onboarding</strong> - Single <code class="language-plaintext highlighter-rouge">docker-compose up</code> command works for new developers</li>
  <li><strong>No environment inconsistencies</strong> - Same Node.js version across all environments</li>
</ul>

<p><strong>Key Insight:</strong> Docker layer optimization and proper build sequencing were crucial for maintaining fast development cycles while supporting the new JavaScript toolchain.</p>

<h2 id="phase-5-asset-pipeline-cleanup">Phase 5: Asset Pipeline Cleanup</h2>

<p>Once our esbuild system was working reliably, we systematically cleaned up the legacy Sprockets configuration and removed obsolete JavaScript files. This cleanup phase was critical for preventing confusion and reducing maintenance burden.</p>

<h3 id="updating-manifestjs-configuration">Updating manifest.js Configuration</h3>

<p>The Sprockets manifest needed significant cleanup to remove JavaScript references:</p>

<p><strong>Before (including all JavaScript):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/assets/config/manifest.js</span>
<span class="c1">//= link_tree ../images</span>
<span class="c1">//= link_directory ../javascripts .js</span>
<span class="c1">//= link_directory ../stylesheets .css</span>
<span class="c1">//= link bootstrap-select.min.js</span>
<span class="c1">//= link_tree ../builds</span>
</code></pre></div></div>

<p><strong>After (JavaScript removed from Sprockets):</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/assets/config/manifest.js</span>
<span class="c1">//= link_tree ../images</span>
<span class="c1">//= link_directory ../stylesheets .css</span>
<span class="c1">//= link_tree ../builds</span>
</code></pre></div></div>

<p><strong>Key Changes:</strong></p>

<ul>
  <li>Removed <code class="language-plaintext highlighter-rouge">//= link_directory ../javascripts .js</code> - No more Sprockets JavaScript processing</li>
  <li>Removed <code class="language-plaintext highlighter-rouge">//= link bootstrap-select.min.js</code> - Now bundled via npm</li>
  <li>Kept <code class="language-plaintext highlighter-rouge">//= link_tree ../builds</code> - esbuild output served by Sprockets</li>
</ul>

<h3 id="removing-sprockets-javascript-processing">Removing Sprockets JavaScript Processing</h3>

<p>Updated production environment to stop JavaScript compression:</p>

<p><strong>Before (Sprockets handling JavaScript):</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/environments/production.rb</span>
<span class="c1"># Compress JavaScripts and CSS.</span>
<span class="n">config</span><span class="p">.</span><span class="nf">assets</span><span class="p">.</span><span class="nf">js_compressor</span> <span class="o">=</span> <span class="ss">:terser</span>
</code></pre></div></div>

<p><strong>After (esbuild handling JavaScript):</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/environments/production.rb</span>
<span class="c1"># Compress CSS.</span>
<span class="c1"># JavaScript is now handled by esbuild, not Sprockets</span>
</code></pre></div></div>

<p>This eliminated the need for server-side JavaScript minification since esbuild handles it during the build process.</p>

<h3 id="gem-dependencies-cleanup">Gem Dependencies Cleanup</h3>

<p>Removed obsolete Ruby gems from Gemfile:</p>

<p><strong>Before:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"jquery-rails"</span>        <span class="c1"># ← Removed: jQuery now via npm</span>
<span class="n">gem</span> <span class="s2">"coffee-rails"</span>        <span class="c1"># ← Removed: No CoffeeScript files</span>
<span class="n">gem</span> <span class="s2">"terser"</span>              <span class="c1"># ← Removed: esbuild handles minification</span>
<span class="n">gem</span> <span class="s2">"babel-transpiler"</span>    <span class="c1"># ← Removed: esbuild handles transpilation</span>
</code></pre></div></div>

<p><strong>After:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"jsbundling-rails"</span>    <span class="c1"># ← Only JavaScript-related gem needed</span>
</code></pre></div></div>

<p><strong>Benefits of Gem Cleanup:</strong></p>

<ul>
  <li><strong>Simplified Bundler dependencies</strong> - 4 fewer gems to manage</li>
  <li><strong>Faster bundle install</strong> - Reduced dependency resolution time</li>
  <li><strong>Cleaner production environment</strong> - Easier management of JavaScript processors</li>
  <li><strong>Reduced attack surface</strong> - Fewer dependencies to maintain and update</li>
</ul>

<h3 id="layout-file-simplification">Layout File Simplification</h3>

<p>Layout files became much cleaner without CDN dependencies:</p>

<p><strong>Before (multiple script tags):</strong></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb --&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://code.jquery.com/jquery-3.7.1.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/js/bootstrap.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/flatpickr"</span><span class="nt">&gt;&lt;/script&gt;</span>

<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span><span class="p">,</span> <span class="s2">"data-turbo-track"</span><span class="p">:</span> <span class="s2">"reload"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><strong>After (single bundled script):</strong></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- app/views/layouts/application.html.erb --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span><span class="p">,</span> <span class="s2">"data-turbo-track"</span><span class="p">:</span> <span class="s2">"reload"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h3 id="cdn-dependency-consolidation">CDN Dependency Consolidation</h3>

<p><strong>Complete elimination of external JavaScript CDNs:</strong></p>

<table>
  <thead>
    <tr>
      <th>Library</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>jQuery</strong></td>
      <td>CDN link</td>
      <td>npm package → bundled</td>
    </tr>
    <tr>
      <td><strong>Bootstrap</strong></td>
      <td>CDN link</td>
      <td>npm package → bundled</td>
    </tr>
    <tr>
      <td><strong>Popper.js</strong></td>
      <td>CDN link</td>
      <td>npm package → bundled</td>
    </tr>
    <tr>
      <td><strong>Flatpickr</strong></td>
      <td>CDN link</td>
      <td>npm package → bundled</td>
    </tr>
    <tr>
      <td><strong>Bootstrap-select</strong></td>
      <td>CDN link</td>
      <td>npm package → bundled</td>
    </tr>
  </tbody>
</table>

<p> </p>

<p><strong>Benefits of CDN Consolidation:</strong></p>

<ul>
  <li><strong>Improved Performance</strong> - Single HTTP request instead of 5-6 separate CDN requests</li>
  <li><strong>Better Caching</strong> - All JavaScript served from same domain with consistent cache headers</li>
  <li><strong>Version Control</strong> - All dependencies locked to specific versions in package.json</li>
  <li><strong>Offline Development</strong> - No external dependencies required for local development</li>
  <li><strong>Security</strong> - No third-party CDN dependencies that could be compromised</li>
</ul>

<h3 id="results-of-asset-pipeline-cleanup">Results of Asset Pipeline Cleanup</h3>

<p>After this cleanup phase, we achieved:</p>

<ul>
  <li><strong>Single source of truth</strong> - esbuild handles all JavaScript, Sprockets handles CSS/images</li>
  <li><strong>Simplified configuration</strong> - Removed 4 gems and multiple CDN dependencies</li>
  <li><strong>Faster builds</strong> - No duplicate JavaScript processing</li>
  <li><strong>Cleaner codebase</strong> - legacy files removed from version control</li>
  <li><strong>Better security</strong> - No external CDN dependencies</li>
  <li><strong>Easier maintenance</strong> - One build system to understand and debug</li>
</ul>

<p><strong>Key Insight:</strong> The cleanup phase proved as important as the migration itself. Having both systems running parallel during development was helpful for validation, but cleaning up promptly after migration prevented confusion and technical debt accumulation.</p>

<h2 id="phase-6-testing-and-validation">Phase 6: Testing and Validation</h2>

<p>The testing and validation phase was critical for ensuring our migration didn’t break any existing functionality. We followed a systematic approach to catch issues early and validate that our new esbuild setup worked identically to the old Sprockets system.</p>

<h3 id="ensuring-functionality-parity">Ensuring Functionality Parity</h3>

<p><strong>Module-wise Testing Approach:</strong></p>

<p>Rather than testing the entire application at once, we tested functionality module by module using our parallel asset pipeline setup:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Testing different modules with different JavaScript bundles --&gt;</span>

<span class="c">&lt;!-- app/views/layouts/user.html.erb - User module testing --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-bundled"</span> <span class="cp">%&gt;</span>  <span class="c">&lt;!-- esbuild dependencies --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application-modern"</span> <span class="cp">%&gt;</span>   <span class="c">&lt;!-- core app logic --&gt;</span>

<span class="c">&lt;!-- app/views/layouts/application.html.erb - Main app (control group) --&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">javascript_include_tag</span> <span class="s2">"application"</span> <span class="cp">%&gt;</span>          <span class="c">&lt;!-- Original Sprockets --&gt;</span>
</code></pre></div></div>

<p><strong>Testing Methodology:</strong></p>

<ol>
  <li><strong>Side-by-side comparison</strong> - Open same page in two browser tabs with different JavaScript bundles</li>
  <li><strong>AJAX functionality verification</strong> - Ensure all dynamic features worked identically</li>
  <li><strong>Cross-browser testing</strong> - Validate across Chrome, Firefox.</li>
</ol>

<h3 id="discovered-issues-and-fixes-during-testing">Discovered Issues and Fixes During Testing</h3>

<p><strong>Issue 1: Rails UJS Compatibility</strong></p>

<p>During initial testing, we found that AJAX functionality on our forms (originally written for <code class="language-plaintext highlighter-rouge">jquery-ujs</code>) was no longer working and behaved differently:</p>

<p><strong>Problem discovered:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This worked with rails/ujs but not with jquery-ujs</span>
<span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#add_line_item</span><span class="dl">"</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:success</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">xhr</span><span class="p">)</span> <span class="p">{</span>
  <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#line_items tbody</span><span class="dl">"</span><span class="p">).</span><span class="nf">append</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Root cause:</strong> Different event object structure between <code class="language-plaintext highlighter-rouge">@rails/ujs</code> and <code class="language-plaintext highlighter-rouge">jquery-ujs</code></p>

<p><strong>Solution implemented:</strong></p>

<p>We stopped using <code class="language-plaintext highlighter-rouge">rails/ujs</code>, reverted to <code class="language-plaintext highlighter-rouge">jquery-ujs</code>, and rewrote the code as follows:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">$</span><span class="p">(</span><span class="nb">document</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:success</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">#add_line_item</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">var</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">detail</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
  <span class="kd">var</span> <span class="nx">responseText</span><span class="p">;</span>

  <span class="k">if </span><span class="p">(</span><span class="k">typeof</span> <span class="nx">response</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">responseText</span> <span class="o">=</span> <span class="nx">response</span><span class="p">;</span>
  <span class="p">}</span> <span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">responseText</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">innerHTML</span><span class="p">;</span>
  <span class="p">}</span> <span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">responseText</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">responseText</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">responseText</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#line_items tbody</span><span class="dl">"</span><span class="p">).</span><span class="nf">append</span><span class="p">(</span><span class="nx">responseText</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Issue 2: Variable Declaration Errors</strong></p>

<p>esbuild’s stricter parsing caught undeclared variables:</p>

<p><strong>Problem discovered:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This worked in Sprockets (loose mode) but failed in esbuild</span>
<span class="nf">triggerTarget</span><span class="p">(</span><span class="nx">trigger</span><span class="p">){</span>
  <span class="nx">targetName</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nf">triggerFor</span><span class="p">(</span><span class="nx">trigger</span><span class="p">)</span> <span class="c1">// ← Undeclared variable</span>
  <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">page</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="s2">`[data-nav-tabs-name="</span><span class="p">${</span><span class="nx">targetName</span><span class="p">}</span><span class="s2">"]`</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Solution implemented:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Fixed version with proper variable declaration</span>
<span class="nf">triggerTarget</span><span class="p">(</span><span class="nx">trigger</span><span class="p">){</span>
  <span class="kd">let</span> <span class="nx">targetName</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nf">triggerFor</span><span class="p">(</span><span class="nx">trigger</span><span class="p">)</span> <span class="c1">// ← Properly declared</span>
  <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">page</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="s2">`[data-nav-tabs-name="</span><span class="p">${</span><span class="nx">targetName</span><span class="p">}</span><span class="s2">"]`</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="production-deployment-considerations">Production Deployment Considerations</h3>

<p><strong>Pre-deployment Validation:</strong></p>

<ol>
  <li><strong>Asset compilation test</strong> - Verified assets built successfully in production environment</li>
  <li><strong>CDN integration</strong> - Confirmed bundled assets served correctly from Rails asset pipeline</li>
  <li><strong>Caching validation</strong> - Ensured proper cache busting with asset fingerprinting</li>
</ol>

<h2 id="lessons-learned-and-gotchas">Lessons Learned and Gotchas</h2>

<p>Our migration taught us several important lessons about moving from Sprockets to modern JavaScript bundling. Here are the key challenges we encountered and practical solutions that saved us significant time and debugging effort.</p>

<h3 id="common-pitfalls-and-how-we-solved-them">Common Pitfalls and How We Solved Them</h3>

<p><strong>Pitfall 1: Assuming All ES6 Files Need Code Changes</strong></p>

<p><strong>What we initially thought:</strong>
“All our <code class="language-plaintext highlighter-rouge">.es6</code> files will need significant refactoring to work with esbuild”</p>

<p><strong>Reality:</strong>
Our <code class="language-plaintext highlighter-rouge">.es6</code> files were mostly vanilla JavaScript with ES6 syntax that worked perfectly in modern browsers.</p>

<p><strong>Solution:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># This simple approach worked for 30+ files:</span>
<span class="nb">mv </span>file.es6 file.js
<span class="c"># No code changes needed</span>
</code></pre></div></div>

<p><strong>Lesson:</strong> Always audit your existing code first. Don’t assume you need complex transpilation setups.</p>

<p><strong>Pitfall 2: Trying to Import Everything as ES6 Modules</strong></p>

<p><strong>What we initially tried:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/application.js - WRONG approach</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">setupuserTableEvents</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./users.js</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">calculateTotal</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./line_items.js</span><span class="dl">"</span><span class="p">;</span>
<span class="c1">// ... trying to make everything modular</span>
</code></pre></div></div>

<p><strong>Problem:</strong> Rails <code class="language-plaintext highlighter-rouge">.js.erb</code> files expect global functions, not ES6 module exports.</p>

<p><strong>Reality-based solution:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/users.js - WORKING approach</span>
<span class="kd">function</span> <span class="nf">setupuserTableEvents</span><span class="p">()</span> <span class="p">{</span>
  <span class="c1">// Implementation here</span>
<span class="p">}</span>

<span class="c1">// Make available globally for Rails integration</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">setupuserTableEvents</span> <span class="o">=</span> <span class="nx">setupuserTableEvents</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Lesson:</strong> In Rails applications, you often need to maintain global function access alongside modern module patterns.</p>

<p><strong>Pitfall 3: Underestimating jQuery Timing Issues</strong></p>

<p><strong>What we initially thought:</strong>
“jQuery should just work if we import it first”</p>

<p><strong>What actually happened:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This caused random failures</span>
<span class="k">import</span> <span class="nx">$</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">jquery</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">$</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>

<span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./users.js</span><span class="dl">"</span><span class="p">);</span> <span class="c1">// Sometimes executed before $ was ready</span>
</code></pre></div></div>

<p><strong>Solution that actually works:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/javascript/jquery-loader.js</span>
<span class="k">import</span> <span class="nx">$</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">jquery</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">jQuery</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">$</span> <span class="o">=</span> <span class="nx">$</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">jqueryReady</span> <span class="o">=</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">resolve</span><span class="p">(</span><span class="nx">$</span><span class="p">);</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">withJquery</span><span class="p">(</span><span class="nx">callback</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">jq</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">jqueryReady</span><span class="p">;</span>
  <span class="k">return</span> <span class="nf">callback</span><span class="p">(</span><span class="nx">jq</span><span class="p">);</span>
<span class="p">}</span>

<span class="c1">// app/javascript/application.js</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">withJquery</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./jquery-loader.js</span><span class="dl">"</span><span class="p">;</span>

<span class="nf">withJquery</span><span class="p">(</span><span class="k">async </span><span class="p">(</span><span class="nx">$</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// All imports happen after jQuery is confirmed available</span>
  <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./users.js</span><span class="dl">"</span><span class="p">);</span>
  <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="dl">"</span><span class="s2">./line_items.js</span><span class="dl">"</span><span class="p">);</span>
  <span class="c1">// ...</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Lesson:</strong> Asynchronous imports require explicit dependency management. Don’t rely on import order for timing-sensitive dependencies.</p>

<h3 id="jquery-compatibility-challenges">jQuery Compatibility Challenges</h3>

<p><strong>Challenge 1: Different UJS Event Structures</strong></p>

<p><strong>Problem:</strong> <code class="language-plaintext highlighter-rouge">@rails/ujs</code> and <code class="language-plaintext highlighter-rouge">jquery-ujs</code> fire events with different data structures.</p>

<p><strong>Symptoms:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This worked with Sprockets + jquery-ujs</span>
<span class="nf">$</span><span class="p">(</span><span class="dl">"</span><span class="s2">#form</span><span class="dl">"</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">ajax:success</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">xhr</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">);</span> <span class="c1">// ← Undefined with @rails/ujs</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Solution:</strong> Just switch to <code class="language-plaintext highlighter-rouge">jquery-ujs</code> for consistency:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"jquery-ujs"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.2.3"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Challenge 2: Plugin Integration Patterns</strong></p>

<p><strong>Problem:</strong> Some jQuery plugins expect specific global setup.</p>

<p><strong>Example with Flatpickr:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// WRONG - Doesn't make it available for legacy code</span>
<span class="k">import</span> <span class="nx">flatpickr</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">flatpickr</span><span class="dl">"</span><span class="p">;</span>

<span class="c1">// CORRECT - Maintains both modern and legacy access</span>
<span class="k">import</span> <span class="nx">flatpickr</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">flatpickr</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">flatpickr</span> <span class="o">=</span> <span class="nx">flatpickr</span><span class="p">;</span>

<span class="c1">// Add as jQuery plugin for legacy compatibility</span>
<span class="nx">$</span><span class="p">.</span><span class="nx">fn</span><span class="p">.</span><span class="nx">flatpickr</span> <span class="o">=</span> <span class="nf">function </span><span class="p">(</span><span class="nx">config</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
    <span class="nf">flatpickr</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="nx">config</span><span class="p">);</span>
  <span class="p">});</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="import-order-dependencies">Import Order Dependencies</h3>

<p><strong>Challenge: Bootstrap + Popper.js Dependency Chain</strong></p>

<p><strong>Problem discovered:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// This order caused "Popper is not defined" errors</span>
<span class="k">import</span> <span class="dl">"</span><span class="s2">bootstrap</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Popper</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">popper.js</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Correct order:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Popper must be available before Bootstrap imports</span>
<span class="k">import</span> <span class="nx">Popper</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">popper.js</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">Popper</span> <span class="o">=</span> <span class="nx">Popper</span><span class="p">;</span>

<span class="k">import</span> <span class="dl">"</span><span class="s2">bootstrap</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Lesson:</strong> Some libraries have implicit global dependencies. Check the documentation and test thoroughly.</p>

<h3 id="key-takeaways">Key Takeaways</h3>

<ol>
  <li><strong>Test early and often</strong> - Don’t wait until the end to test functionality</li>
  <li><strong>Maintain compatibility layers</strong> - Global function access is often necessary</li>
  <li><strong>Handle timing explicitly</strong> - Don’t rely on import order for critical dependencies</li>
  <li><strong>Document decisions</strong> - Note why you chose specific patterns for future maintainers</li>
</ol>

<p><strong>Most Important Lesson:</strong> The biggest time-saver was creating the parallel asset pipeline system. This allowed us to test incrementally and compare behavior directly, catching issues early when they were easier to fix.</p>

<h2 id="results-and-benefits">Results and Benefits</h2>

<p>After completing our migration from Sprockets to <code class="language-plaintext highlighter-rouge">jsbundling-rails</code> + <code class="language-plaintext highlighter-rouge">esbuild</code>, the improvements exceeded our expectations across multiple dimensions. Here’s a comprehensive analysis of the tangible benefits we achieved.</p>

<h3 id="maintenance-advantages">Maintenance Advantages</h3>

<p><strong>Simplified Dependency Management:</strong></p>

<p><strong>Before (Sprockets approach):</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile - JavaScript managed via Ruby gems</span>
<span class="n">gem</span> <span class="s1">'jquery-rails'</span>        <span class="c1"># jQuery via Ruby wrapper</span>
<span class="n">gem</span> <span class="s1">'coffee-rails'</span>        <span class="c1"># CoffeeScript support</span>
<span class="n">gem</span> <span class="s1">'terser'</span>              <span class="c1"># JavaScript minification</span>
<span class="n">gem</span> <span class="s1">'babel-transpiler'</span>    <span class="c1"># ES6 transpilation</span>

<span class="c1"># Plus CDN links in layout files:</span>
<span class="c1"># &lt;script src="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/..."&gt;</span>
<span class="c1"># &lt;script src="https://code.jquery.com/jquery-3.7.1.min.js"&gt;</span>
</code></pre></div></div>

<p><strong>After (npm approach):</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"jquery"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.7.1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"bootstrap"</span><span class="p">:</span><span class="w"> </span><span class="s2">"4.4.1"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"flatpickr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.6.13"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"esbuild"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.25.6"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Benefits:</strong></p>

<ul>
  <li><strong>4 fewer Ruby gems</strong> to maintain and update</li>
  <li><strong>Single source of truth</strong> for JavaScript dependencies</li>
  <li><strong>Explicit version control</strong> in package.json vs implicit CDN versions</li>
  <li><strong>Offline development</strong> without CDN dependencies</li>
</ul>

<p><strong>Reduced Configuration Complexity:</strong></p>

<p><strong>Before (multiple configuration points):</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/environments/production.rb</span>
<span class="n">config</span><span class="p">.</span><span class="nf">assets</span><span class="p">.</span><span class="nf">js_compressor</span> <span class="o">=</span> <span class="ss">:terser</span>

<span class="c1"># app/assets/config/manifest.js</span>
<span class="sr">//</span><span class="o">=</span> <span class="n">link_directory</span> <span class="o">..</span><span class="sr">/javascripts .js
/</span><span class="o">/=</span> <span class="n">link</span> <span class="n">bootstrap</span><span class="o">-</span><span class="nb">select</span><span class="p">.</span><span class="nf">min</span><span class="p">.</span><span class="nf">js</span>

<span class="c1"># Various gem-specific configuration</span>
</code></pre></div></div>

<p><strong>After (single build configuration):</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"esbuild app/javascript/application.js --bundle --sourcemap --format=iife --outfile=app/assets/builds/application.js --minify"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Network Performance:</strong></p>

<ul>
  <li><strong>Reduced latency:</strong> Single request vs multiple CDN requests</li>
  <li><strong>Better caching:</strong> All JavaScript served from same domain with consistent cache headers</li>
  <li><strong>Improved reliability:</strong> No dependency on external CDN availability</li>
</ul>

<p><strong>Access to Modern JavaScript Ecosystem:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Now possible - direct npm package usage</span>
<span class="k">import</span> <span class="nx">dayjs</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">dayjs</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">axios</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">axios</span><span class="dl">"</span><span class="p">;</span>

<span class="c1">// Previously required manual vendoring or gem wrappers</span>
</code></pre></div></div>

<p><strong>Infrastructure Benefits:</strong></p>

<ul>
  <li><strong>Reduced CDN costs:</strong> No external JavaScript CDN dependencies</li>
  <li><strong>Simplified deployment:</strong> Single asset pipeline reduces deployment complexity</li>
</ul>

<p><strong>Risk Reduction:</strong></p>

<ul>
  <li><strong>Fewer external dependencies:</strong> Eliminates CDN availability risks</li>
  <li><strong>Modern tooling:</strong> Reduces risk of legacy tool abandonment</li>
  <li><strong>Industry alignment:</strong> Easier to hire developers familiar with standard JavaScript tooling</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Some of the specific problems and solutions documented in this blog post are unique to our particular Rails application and its configuration. However, we’ve shared them in case you encounter similar challenges during your own migration. The patterns and approaches described should be adaptable to most Rails applications making this transition.</p>

<h2 id="need-help-with-your-javascript-modernization">Need Help with Your JavaScript Modernization?</h2>

<p>If you’re considering a similar migration for your Rails application or need assistance with JavaScript modernization, dependency cleanup, or
build optimization, we’d be happy to help.</p>

<p>Whether you’re dealing with a complex legacy JavaScript codebase, evaluating different bundling approaches, or planning a gradual migration
strategy, our experience with this transition can help you avoid common pitfalls and achieve a smooth modernization.</p>

<p>Feel free to reach out if you’d like to discuss your specific situation or explore how we can help
<a href="https://www.fastruby.io/#contactus">modernize your JavaScript infrastructure</a>.</p>]]></content><author><name>rishijain</name></author><category term="javascript" /><summary type="html"><![CDATA[A six-phase guide to migrating a Rails app from Sprockets to jsbundling-rails + esbuild. Covers jQuery, Bootstrap, Docker, gotchas, and lessons learned.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/migrate-sprockets-jsbundling.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/migrate-sprockets-jsbundling.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A Better Experience for Everyone: Usability Meets Accessibility</title><link href="https://www.fastruby.io/blog/usability-and-accessibility-for-better-user-experience.html" rel="alternate" type="text/html" title="A Better Experience for Everyone: Usability Meets Accessibility" /><published>2026-04-28T07:12:29-04:00</published><updated>2026-04-28T07:12:29-04:00</updated><id>https://www.fastruby.io/blog/usability-and-accessibility-for-better-user-experience</id><content type="html" xml:base="https://www.fastruby.io/blog/usability-and-accessibility-for-better-user-experience.html"><![CDATA[<p>People focus so much on <code class="language-plaintext highlighter-rouge">&lt;h1&gt;</code> and <code class="language-plaintext highlighter-rouge">alt</code> attributes that they forget about usability.</p>

<h2 id="introduction">Introduction</h2>

<p>When discussing accessibility (<strong>a11y</strong>), we all focus on the structure of our <code class="language-plaintext highlighter-rouge">&lt;h1&gt;</code>/<code class="language-plaintext highlighter-rouge">&lt;h2&gt;</code>, the <code class="language-plaintext highlighter-rouge">alt</code> image texts, the contrast,
and all the necessary rules to be covered. The problem is that during the process, we forget to think about usability:
Is the page saying what it is supposed to be saying? Does the image description reflect what you can see there? Or take
link text as an example: Does “Read More” explain what content the user is about to access?</p>

<p>Most of the time, usability is overshadowed by a focus on compliance with accessibility standards.
As a result, we end up with a site that passes validation but neglects to measure usability… or worse,
we don’t even consider it.</p>

<!--more-->

<p>This article will explore a few key concepts and examples to highlight why usability is an essential complement to accessibility.</p>

<h3 id="what-is-usability">What is Usability?</h3>

<p><img src="/blog/assets/images/dont-make-me-think.jpg" alt="Don't Make Me Think book cover by Steve Krug" style="width:25%; float:right" /></p>

<p>Most of the ideas we will cover here come from “Don’t Make Me Think! A Common Sense Approach to Web Usability”<sup>1</sup>
by the usability guru Steve Krug, who helps to understand the principles of intuitive navigation and information design.</p>

<p>The book starts with a conversation that I would like to share:</p>

<ul>
  <li>“What’s the most important thing I should do if I want to make sure my website is easy to use?”</li>
</ul>

<p>The answer is simple. It’s not “Nothing important should ever be more than two clicks away,”
or “Speak the user’s language,” or even “Be consistent.” It’s…</p>

<p><strong><center>“Don’t make me think!”</center></strong></p>

<p>Usability is about making things simple and intuitive.</p>

<p>A website or app should require minimal effort for users to navigate and understand.</p>

<p>The key questions to ask are:</p>

<ul>
  <li>Can users easily figure out how to interact with your site?</li>
  <li>Does the content say what it’s supposed to say?</li>
  <li>Are the goals of your design clear at a glance?</li>
</ul>

<h2 id="the-missing-link-between-usability-and-accessibility">The Missing Link Between Usability and Accessibility</h2>

<p>We usually use an external validator such as <a href="https://www.deque.com/axe/">Axe Deque</a><sup>2</sup> or automated
accessibility testing tools like <a href="https://github.com/dequelabs/axe-core-gems">axe-core-gems</a><sup>3</sup> to check
if we live up to the WCAG standards<sup>4</sup>.</p>

<p>Still, at the end of the day, it is more of a grammar checker than a spell checker. They are nice and
helpful tools that will help us fix contrast andmissing text and give us a list of corrections, some of them
just suggestions, to try to reduce the typically long list.</p>

<p>However, these technical fixes do not ensure usability.</p>

<p>For example, if an image’s <code class="language-plaintext highlighter-rouge">alt</code> text says, “Image of a person,”but the image is actually an instructional graphic, the <code class="language-plaintext highlighter-rouge">alt</code> text fails to convey its purpose.</p>

<p>Usability ensures <code class="language-plaintext highlighter-rouge">alt</code> text communicates meaning.</p>

<p>Correct nested headings &lt;h1&gt;/&lt;h2&gt; help a lot to the screen readers, but if your &lt;h1&gt; reads “Welcome” without
context at all; yes, it’s technically accessible but practically unhelpful.</p>

<h2 id="a-few-concepts-to-consider">A Few Concepts to Consider</h2>

<p>From our book, we can walk through a few concepts that I consider are the most interesting ones:</p>

<h3 id="the-simplicity-concept">The Simplicity Concept</h3>

<p>Making it simple, not necessary is something easy to do. Each iteration that you plan in your website,
since a link in your sidebar nav until a form with multiple steps, must be intuitive.</p>

<p>Wear the visitor hat for a moment and start using your site. As soon as you notice a delay of seconds or
milliseconds, that moment that makes you doubt about what you are doing, is when you realize that there is
an opportunity to decrease the complexity.</p>

<h3 id="use-the-right-words">Use The Right Words</h3>

<p>Did you take in mind that no one is ever going to read all the entire content on your home page or
that a visitor would think that it is necessary to read everything to understand what is going on?</p>

<p>Do an exercise of reducing the total of words, let’s say 30%, on your site. If you find that it is not
a problem and that your site still making sense, then you decrease the noise level of the page, you are
making your content more prominent, and also given that your pages are shortened, you increase usability,
for example, avoid unnecessary scrolling in long sites.</p>

<h2 id="examples-of-doing-it-right">Examples of Doing It Right</h2>
<p><strong>Label text on a link:</strong></p>

<p><strong><span style="color:#ff1d00">bad:</span></strong> “Click here” on a link text<br />
<strong><span style="color:#00d242">good:</span></strong> “Learn More About Our Services”</p>

<p>The first option says nothing at all.</p>

<p>Instead, the second one gives you context for visual and assistive technologies making it better for
usability and accessibility purposes.</p>

<p><strong>Alt text on an image:</strong></p>

<p><strong><span style="color:#ff1d00">bad:</span></strong> “Image of a cat”<br />
<strong><span style="color:#00d242">good:</span></strong> “A cat lying on its back being petted by a person”</p>

<p>The second description is much more descriptive and meaningful about the image. On the first one, the text is
vague and does not say too much about it.</p>

<p>Probably, at first, you spend business time to find or create that specific image for a real intention, and then,
when you can describe it, and people in general, miss the intention and the motivation when writing the text.</p>

<p><strong>Form placeholders:</strong></p>

<p><strong><span style="color:#ff1d00">bad:</span></strong> A form with placeholders in all the inputs <br />
<strong><span style="color:#00d242">good:</span></strong> A form with also a label like “full name”</p>

<p>It is considered a poor practice as it can significantly impact usability, especially for users with visual
or cognitive impairments.</p>

<h2 id="last-words">Last Words</h2>

<p>We are conscious that <strong>usability</strong> has even more complexity than <strong>accessibility</strong>, but we must be conscious
that they go hand in hand.</p>

<p>When you work on a11y take extra time to keep focus at the same time if makes sense of what you are describing
or writing.</p>

<p>When you work on accessibility, take the extra time to ask yourself:</p>

<ul>
  <li>Does this make sense?</li>
  <li>Does it serve its purpose clearly?</li>
</ul>

<p>By considering both usability and accessibility together, you create a better experience for everyone.</p>

<h2 id="references">References</h2>

<ol>
  <li>Krug, Steve. Don’t Make Me Think! A Common Sense Approach to Web Usability (2nd Edition). New Riders Publishing, 2005.</li>
  <li>Deque Systems. <a href="https://www.deque.com/axe/">Axe Accessibility Testing Tools</a></li>
  <li>Deque Labs. <a href="https://github.com/dequelabs/axe-core-gems.">axe-core-gems</a>: Accessibility Testing for Ruby and Rails Applications</li>
  <li><a href="https://www.w3.org/TR/WCAG21/">World Wide Web Consortium (W3C). Web Content Accessibility Guidelines (WCAG) 2.1</a></li>
  <li>A Garriga, Dolores and others. <a href="https://www.memoria.fahce.unlp.edu.ar/library?a=d&amp;c=proyecto&amp;d=Jpy1174">El espectro gramatical: formas, significados y conducta humana</a>. Revista Española de Lingüística, 2003.</li>
  <li>Columbia School Linguistic Society. <a href="https://www.csling.org/">Columbia School of Linguistics Official Website</a></li>
</ol>]]></content><author><name>juliolucero</name></author><category term="best-practices" /><summary type="html"><![CDATA[Learn why usability is just as important as accessibility in web design. Explore key concepts and examples that emphasize creating truly inclusive and user-friendly experiences.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/usability-meets-accessibility-for-better-user-experience.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/usability-meets-accessibility-for-better-user-experience.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Migrating a Rails App from Heroku to Railway</title><link href="https://www.fastruby.io/blog/migrating-from-heroku-to-railway.html" rel="alternate" type="text/html" title="Migrating a Rails App from Heroku to Railway" /><published>2026-04-15T05:23:00-04:00</published><updated>2026-04-15T05:23:00-04:00</updated><id>https://www.fastruby.io/blog/migrating-from-heroku-to-railway</id><content type="html" xml:base="https://www.fastruby.io/blog/migrating-from-heroku-to-railway.html"><![CDATA[<p>Last weekend I migrated my Doctor’s App from Heroku to Railway.</p>

<p>It’s a multi-tenant Rails app where each hospital gets its own subdomain, <code class="language-plaintext highlighter-rouge">one.doctors.com</code>, <code class="language-plaintext highlighter-rouge">two.doctors.com</code>, and so on.</p>

<p>Five hospitals, around 25,000 appointments, 9,700+ patients. Not huge, but not trivial either.</p>

<p>Here’s how it went, including the part where I accidentally broke the database.</p>

<!--more-->

<h2 id="the-setup">The setup</h2>

<p>I already had a Railway project running with a test domain (<code class="language-plaintext highlighter-rouge">*.juanvasquez.dev</code>) from earlier experiments. The web service was deployed from GitHub and the Postgres 17 instance was co-located in <code class="language-plaintext highlighter-rouge">us-east4</code>. Cloudflare R2 handles file storage, that stays the same regardless of where the app runs.</p>

<p>The plan was simple: put Heroku in maintenance mode, dump the database, restore it to Railway, flip the DNS, and go home.</p>

<h2 id="the-database-restore">The database restore</h2>

<p>First, I captured a fresh Heroku backup and downloaded it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku pg:backups:capture <span class="nt">--app</span> doctors
heroku pg:backups:download <span class="nt">--app</span> doctors <span class="nt">--output</span> /tmp/heroku_backup.dump
</code></pre></div></div>

<p>Then I wiped the Railway database and restored:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Wipe</span>
psql <span class="nt">-h</span> &lt;railway-host&gt; <span class="nt">-p</span> &lt;port&gt; <span class="nt">-U</span> postgres <span class="nt">-d</span> database_name <span class="se">\</span>
  <span class="nt">-c</span> <span class="s2">"DROP SCHEMA public CASCADE; CREATE SCHEMA public;"</span>

<span class="c"># Restore</span>
pg_restore <span class="nt">--verbose</span> <span class="nt">--no-owner</span> <span class="nt">--no-acl</span> <span class="se">\</span>
  <span class="nt">-h</span> &lt;railway-host&gt; <span class="nt">-p</span> &lt;port&gt; <span class="nt">-U</span> postgres <span class="nt">-d</span> database_name /tmp/heroku_backup.dump
</code></pre></div></div>

<p>The restore threw two errors, both about the <code class="language-plaintext highlighter-rouge">unaccent</code> extension. Heroku installs extensions in a <code class="language-plaintext highlighter-rouge">heroku_ext</code> schema that doesn’t exist on Railway. The fix is to just create it manually afterward:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>psql <span class="nt">-h</span> &lt;railway-host&gt; <span class="nt">-p</span> &lt;port&gt; <span class="nt">-U</span> postgres <span class="nt">-d</span> database_name <span class="se">\</span>
  <span class="nt">-c</span> <span class="s2">"CREATE EXTENSION IF NOT EXISTS unaccent;"</span>
</code></pre></div></div>

<p>Everything else restored cleanly. I verified every table:</p>

<table>
  <thead>
    <tr>
      <th>Table</th>
      <th>Heroku</th>
      <th>Railway</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>users</td>
      <td>9,752</td>
      <td>9,752</td>
    </tr>
    <tr>
      <td>appointments</td>
      <td>25,481</td>
      <td>25,481</td>
    </tr>
    <tr>
      <td>addresses</td>
      <td>9,835</td>
      <td>9,835</td>
    </tr>
    <tr>
      <td>patient_referrals</td>
      <td>1,211</td>
      <td>1,211</td>
    </tr>
    <tr>
      <td>hospitals</td>
      <td>5</td>
      <td>5</td>
    </tr>
  </tbody>
</table>

<p>All 12 tables matched exactly. If you take one thing from this post: <strong>always verify row counts after a restore</strong>.</p>

<h2 id="the-moment-i-broke-the-database">The moment I broke the database</h2>

<p>With the data restored, I wanted to trigger a deploy on the web service. I ran:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>railway up <span class="nt">--detach</span>
</code></pre></div></div>

<p>Without <code class="language-plaintext highlighter-rouge">--service web</code>.</p>

<p>That command deployed my Rails application code onto the Postgres service. It replaced the PostgreSQL 17 container with Puma. The database was now a Rails web server that couldn’t handle Postgres connections.</p>

<p>The logs told the story immediately:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP parse error, malformed request: #&lt;Puma::HttpParserError:
Invalid HTTP format, parsing fails. Are you trying to open
an SSL connection to a non-SSL Puma?&gt;
</code></pre></div></div>

<p>The web service was trying to connect to Postgres, but Postgres was now running Puma, responding to TCP connections with HTTP errors.</p>

<p>The fix was to roll back the Postgres service to its last good deployment. Railway’s CLI doesn’t have a rollback command, so I used the dashboard to roll back the deployment.</p>

<p>After about 45 seconds, Postgres was back. Data intact. Lesson learned: <strong>always pass <code class="language-plaintext highlighter-rouge">--service web</code> when deploying</strong>.</p>

<h2 id="flipping-the-domain">Flipping the domain</h2>

<p>Removing the test domain was another adventure. Railway’s CLI can add domains but can’t delete them. I used the dashboard to remove it.</p>

<p>Then I added the production wildcard domain:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>railway domain <span class="s2">"*.doctors.com"</span> <span class="nt">--service</span> web <span class="nt">--port</span> 8080
</code></pre></div></div>

<p>Railway returned the DNS records I needed. In Squarespace (my domain registrar), I added:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Host</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CNAME</td>
      <td><code class="language-plaintext highlighter-rouge">*</code></td>
      <td><code class="language-plaintext highlighter-rouge">znjcefnu.up.railway.app</code></td>
    </tr>
    <tr>
      <td>CNAME</td>
      <td><code class="language-plaintext highlighter-rouge">_acme-challenge</code></td>
      <td><code class="language-plaintext highlighter-rouge">znjcefnu.authorize.railwaydns.net</code></td>
    </tr>
  </tbody>
</table>

<p>There was also a <code class="language-plaintext highlighter-rouge">_railway-verify</code> record for domain ownership. I initially tried adding it as a <code class="language-plaintext highlighter-rouge">CNAME</code>, but Squarespace rejected the value; it’s actually a <strong>TXT record</strong>, not a <code class="language-plaintext highlighter-rouge">CNAME</code>. Small thing, but it tripped me up.</p>

<p>DNS propagated fast. Within a couple of minutes, Railway confirmed the domain was verified and SSL was provisioned.</p>

<h2 id="one-more-thing-rack_env">One more thing: RACK_ENV</h2>

<p>The first request to <code class="language-plaintext highlighter-rouge">demo.doctors.com</code> returned a 500. I checked the logs and saw… a Rails development error page. <code class="language-plaintext highlighter-rouge">RACK_ENV</code> was set to <code class="language-plaintext highlighter-rouge">development</code>. A quick variable update and redeploy fixed it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>railway variable <span class="nb">set </span><span class="nv">RACK_ENV</span><span class="o">=</span>production <span class="nt">--service</span> web
</code></pre></div></div>

<p>Then all five hospital subdomains came back with 200s.</p>

<h2 id="trial-plan-limitations">Trial plan limitations</h2>

<p>Railway’s trial plan only allows <strong>one custom domain per service</strong>. The wildcard <code class="language-plaintext highlighter-rouge">*.doctors.com</code> uses that single slot, which works great for multi-tenancy, every subdomain routes correctly. But I can’t also add the root domain <code class="language-plaintext highlighter-rouge">doctors.com</code>. For now, I’ll handle that with a redirect at the registrar level.</p>

<h2 id="pricing">Pricing</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Heroku</th>
      <th>Railway</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Web service</td>
      <td>$7/mo (Basic dyno)</td>
      <td>Usage-based (~$5/mo)</td>
    </tr>
    <tr>
      <td>Postgres</td>
      <td>$5/mo (Mini)</td>
      <td>Included (500MB)</td>
    </tr>
    <tr>
      <td>Custom domains</td>
      <td>Included</td>
      <td>1 per service (trial)</td>
    </tr>
    <tr>
      <td>SSL</td>
      <td>Automatic</td>
      <td>Automatic</td>
    </tr>
    <tr>
      <td>Chrome buildpack</td>
      <td>Required for old PDF setup</td>
      <td>Not needed (using Prawn now)</td>
    </tr>
  </tbody>
</table>

<p>For my scale, Railway is slightly cheaper. The real win is simplicity, no buildpack configuration, no add-on marketplace to navigate, and Postgres is just there.</p>

<h2 id="what-i-also-did">What I also did</h2>

<p>While I was at it, I replaced Sentry with <a href="https://app.honeybadger.io/users/sign_up?referred_by=8eTFBiZ7EUHt8iCF">Honeybadger</a> <em>(referral link)</em> for error tracking. Sentry’s initializer still referenced Heroku env vars, so it was a good time to clean house. Honeybadger has a free plan, built-in uptime monitoring, and the Rails setup is just a YAML file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/honeybadger.yml</span>
<span class="na">api_key</span><span class="pi">:</span> <span class="s">&lt;%= ENV.fetch("HONEYBADGER_API_KEY", "") %&gt;</span>
<span class="na">env</span><span class="pi">:</span> <span class="s">&lt;%= Rails.env %&gt;</span>
<span class="na">exceptions</span><span class="pi">:</span>
  <span class="na">enabled</span><span class="pi">:</span> <span class="s">&lt;%= Rails.env.production? %&gt;</span>
</code></pre></div></div>

<p>I also updated the CI pipeline, upgraded Postgres from 10.13 to 17 (matching production) and Node.js from 20 to 22 (matching <code class="language-plaintext highlighter-rouge">package.json</code>). Removed the Puppeteer and Chrome setup steps that were left over from when the app used Grover for PDF generation.</p>

<h2 id="things-id-tell-myself-before-starting">Things I’d tell myself before starting</h2>

<ol>
  <li><strong>Verify row counts after every restore.</strong> Don’t trust “no errors”, count the rows.</li>
  <li><strong>Always specify <code class="language-plaintext highlighter-rouge">--service</code> when running Railway CLI commands.</strong> Especially <code class="language-plaintext highlighter-rouge">railway up</code>.</li>
  <li><strong>Railway’s CLI can’t do everything.</strong> Domain deletion and deployment rollbacks need to be done through the dashboard.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">railway run</code> executes locally</strong>, not on Railway’s infrastructure. Use <code class="language-plaintext highlighter-rouge">railway shell</code> for remote access.</li>
  <li><strong>Heroku’s <code class="language-plaintext highlighter-rouge">heroku_ext</code> schema for extensions doesn’t exist on Railway.</strong> Expect restore errors for extensions, and re-create them manually.</li>
  <li><strong>Check your RACK_ENV.</strong> It seems obvious, but it’s easy to forget when you’re focused on the database.</li>
  <li><strong>The <code class="language-plaintext highlighter-rouge">_railway-verify</code> DNS record is a TXT record</strong>, even though it looks like it could be a CNAME. Your registrar will reject it if you pick the wrong type.</li>
</ol>

<h2 id="fair-warning">Fair warning</h2>

<p>Since migrating, I’ve seen reports from other developers that give me pause. One team experienced <a href="https://www.reddit.com/r/rails/comments/1s51mfc/railway_vs_render_heroku_digital_ocean_fly_etc/">persistent 150-200ms request queuing</a> on Railway that they couldn’t resolve even with Pro plan support, response times that were 40ms on Heroku, Render, and DigitalOcean. Another long-time customer <a href="https://x.com/euboid/status/2038729202602500376">reported a caching misconfiguration</a> that leaked user data between accounts, on top of weeks of near-daily incidents.</p>

<p>I measured my own response times after reading these reports, and for my scale they’re good enough. But if you’re running something larger, do thorough stress testing before committing, and have a rollback plan. Railway is young, and that cuts both ways: fast iteration, but also growing pains.</p>

<h2 id="was-it-worth-it">Was it worth it?</h2>

<p>The whole migration took about an hour. Most of that was waiting for DNS propagation and debugging the Postgres incident. The actual work, dump, restore, set variables, flip DNS, was maybe 30 minutes.</p>

<p>Railway feels like what Heroku should have become. The dashboard is clean, deploys are fast, and the Postgres integration just works. I miss <code class="language-plaintext highlighter-rouge">heroku run</code> (Railway’s local execution model is confusing at first), but <code class="language-plaintext highlighter-rouge">railway shell</code> covers most cases.</p>

<p>For a small multi-tenant Rails app like mine, it’s a good fit. But I’m keeping my Heroku knowledge fresh, just in case.</p>]]></content><author><name>juanvqz</name></author><category term="ruby" /><summary type="html"><![CDATA[How I migrated a multi-tenant Rails app from Heroku to Railway in about an hour, including the part where I accidentally broke the database.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/migrating-from-heroku-to-railway.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/migrating-from-heroku-to-railway.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Our Rails Upgrade Methodology as Claude Code Skills</title><link href="https://www.fastruby.io/blog/open-source-claude-code-skill-for-rails-upgrades.html" rel="alternate" type="text/html" title="Our Rails Upgrade Methodology as Claude Code Skills" /><published>2026-03-27T06:00:00-04:00</published><updated>2026-03-27T06:00:00-04:00</updated><id>https://www.fastruby.io/blog/open-source-claude-code-skill-for-rails-upgrades</id><content type="html" xml:base="https://www.fastruby.io/blog/open-source-claude-code-skill-for-rails-upgrades.html"><![CDATA[<p>For more than 8 years, we have been publishing detailed guides on how to upgrade Rails applications.</p>

<p>We have documented every minor version from Rails 2.3 through 8.1 in our
<a href="https://www.fastruby.io/blog/rails/upgrade/rails-upgrade-series.html">Rails Upgrade Series</a> and
distilled our methodology into an ebook: <a href="https://www.fastruby.io/">The Complete Guide to Upgrade Rails</a></p>

<p><img src="/blog/assets/images/upgrade-rails-ebook-free-by-fastruby-io.png" alt="Our free e-book to upgrade rails is available on the FastRuby.io homepage" /></p>

<p>All of that knowledge comes from more than <strong>60,000 developer-hours</strong> of hands-on upgrade work for
companies of all sizes, from solo-founded SaaS products to huge Rails monoliths running at
Fortune 500 public companies.</p>

<p>Today, we are making that methodology available to everyone as an open source
<a href="https://code.claude.com/docs/en/skills">Claude Code Skill</a>:
<a href="https://github.com/ombulabs/claude-code_rails-upgrade-skill"><strong>claude-code_rails-upgrade-skill</strong></a>.</p>

<!--more-->

<h2 id="why-a-claude-code-skill">Why a Claude Code Skill?</h2>

<p>Claude Code is a powerful AI assistant for software engineering. It can read your codebase, run
commands, and propose changes.</p>

<p>However, when it comes to Rails upgrades, general programming intelligence is not enough. It can
lead you down the wrong path.</p>

<p>A Rails upgrade is not just about fixing deprecation warnings and updating gem versions.</p>

<p>It requires a battle-tested methodology: a sequence of steps, a testing strategy, and a set of
opinions about how to manage risk during the upgrade.</p>

<p><img src="/blog/assets/images/rails-upgrade-version-jump-steps.png" alt="Our proven methodology heavily relies on your test suite and dual booting your Ruby application" /></p>

<p>Without that structure, even the most capable AI will take shortcuts that create problems down
the road.</p>

<p>That is why we built this skill and made it open source.</p>

<p>It gives Claude Code the methodology and domain knowledge it needs to guide you through a Rails
upgrade the FastRuby.io way.</p>

<p>Our teams have already made all the judgment calls that are included in the methodology, so that you
don’t have to delegate those calls to Claude.</p>

<h2 id="why-open-source">Why Open Source?</h2>

<p>We believe that the Rails community benefits when upgrade knowledge is widely accessible. We have
always shared our learnings through blog posts, conference talks and workshops, and open source tools like
<a href="https://github.com/fastruby/next_rails">next_rails</a> and
<a href="https://github.com/fastruby/skunk">Skunk</a>.</p>

<p>Open sourcing this skill is the natural next step.</p>

<p>If you have the time and confidence to upgrade your Rails application yourself, this skill will guide you
through it with the same methodology we use with our clients.</p>

<p>Of course, if you would rather have us handle it, our
<a href="https://www.fastruby.io/monthly-rails-maintenance">fixed-cost, monthly maintenance service</a> and
<a href="https://www.fastruby.io/our-services">upgrade services</a> are always available. I know many leaders in the
industry prefer to have their engineering teams focus on their product roadmap instead of Rails upgrades.</p>

<h2 id="what-makes-this-skill-opinionated">What Makes This Skill Opinionated</h2>

<p>This is not a generic “help me upgrade Rails” prompt. It encodes specific opinions that we have developed over
hundreds of upgrade projects.</p>

<h3 id="it-always-uses-dual-booting">It Always Uses Dual Booting</h3>

<p>The skill will always set up
<a href="https://www.fastruby.io/blog/upgrade-rails/dual-boot/dual-boot-with-rails-6-0-beta.html">dual booting</a>
for your upgrade project.</p>

<p>This means your application runs with both the current and target versions of Rails simultaneously during
the upgrade project.</p>

<p>Claude Code on its own does not know that dual booting is worth the effort. From a pure code perspective,
if we didn’t extend it with a skill, it would advise against this technique because it adds conditionals
everywhere:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="no">NextRails</span><span class="p">.</span><span class="nf">next?</span>
  <span class="c1"># Code for the TARGET version (new behavior)</span>
<span class="k">else</span>
  <span class="c1"># Code for the CURRENT version (old behavior)</span>
<span class="k">end</span>
</code></pre></div></div>

<blockquote>
  <p>While it does add many conditionals to your codebase, it’s worth it. Claude will say that it is unnecessary
complexity. Our teams have learned from experience that dual booting is <strong>essential</strong> for debugging,
testing, and even gradual deployments to production</p>
</blockquote>

<p>Dual booting lets you:</p>

<ul>
  <li>Run your test suite against both Rails versions in CI</li>
  <li>Catch compatibility issues early instead of discovering them after a risky big-bang deploy</li>
  <li>Clean up all the conditionals in one pass after the upgrade is complete</li>
  <li>Quickly debug between two versions of Rails by setting/unsetting the BUNDLE_GEMFILE environment variable</li>
</ul>

<p>Run the test suite (models only) with the current Rails version:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle exec rspec spec/models
</code></pre></div></div>

<p>Then run the test suite with the target Rails version:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BUNDLE_GEMFILE=Gemfile.next bundle exec rspec spec/models
</code></pre></div></div>

<p>Combined with <code class="language-plaintext highlighter-rouge">byebug</code> or <code class="language-plaintext highlighter-rouge">debugger</code> it can be a powerful tool for understanding the internal changes
that happened in Rails that are causing thorny issues in your upgrade project.</p>

<h3 id="it-runs-the-test-suite-constantly">It Runs the Test Suite Constantly</h3>

<p>The skill requires your test suite to pass <strong>before</strong> any upgrade work begins. Then it runs
tests against both the current and target Rails versions throughout the process.</p>

<p>Every change is verified against both versions.</p>

<p>This is non-negotiable. An upgrade without test coverage is a gamble. The skill will block the
upgrade and help you fix failing tests before proceeding.</p>

<h3 id="it-sets-the-right-defaults-first">It Sets the Right Defaults First</h3>

<p>Before upgrading to a new Rails version, the skill verifies that your <code class="language-plaintext highlighter-rouge">config.load_defaults</code>
matches your <em>current</em> Rails version.</p>

<p>If it does not, the skill delegates to our companion skill,
<a href="https://github.com/ombulabs/claude-code_rails-load-defaults-skill">rails-load-defaults-skill</a>, to walk
through each framework default one at a time, grouped by risk tier.</p>

<p>This is a step that many teams skip, and it causes subtle bugs after the upgrade. Getting
your defaults aligned <em>before</em> bumping the Rails version eliminates an entire category of issues.</p>

<h3 id="it-never-skips-versions">It Never Skips Versions</h3>

<p>The skill enforces sequential upgrades. If you are on Rails 5.2 and want to reach 8.1, it will
plan the path: 5.2 -&gt; 6.0 -&gt; 6.1 -&gt; 7.0 -&gt; 7.1 -&gt; 7.2 -&gt; 8.0 -&gt; 8.1.</p>

<blockquote>
  <p>If you really want to do more than one minor version at a time, you can override our skill
by telling Claude what target version you want to upgrade to.</p>
</blockquote>

<p>Each hop is completed fully before moving to the next one.</p>

<p>We have seen what happens when teams try to skip versions. The compound breakage is nearly
impossible to debug. Sequential upgrades are slower on paper but faster in practice.</p>

<h2 id="how-the-three-skills-work-together">How the Three Skills Work Together</h2>

<p>The upgrade process involves three open source skills that work in tandem:</p>

<h3 id="the-ruby-on-rails-upgrade-skill">The Ruby on Rails Upgrade Skill</h3>

<p>This Claude Code skill is the orchestrator. It detects breaking changes, generates upgrade
reports, plans the sequential upgrade path, and coordinates the other two skills throughout
the process.</p>

<p>You can find the source code for this open source skill under the OmbuLabs.ai organization:
<a href="https://github.com/ombulabs/claude-code_rails-upgrade-skill">github.com/ombulabs/claude-code_rails-upgrade-skill</a></p>

<h3 id="the-dual-boot-skill">The Dual Boot Skill</h3>

<p>This skill sets up and manages dual-boot environments using the
<a href="https://github.com/fastruby/next_rails"><code class="language-plaintext highlighter-rouge">next_rails</code></a> gem.</p>

<p>It handles the <code class="language-plaintext highlighter-rouge">Gemfile.next</code> setup, teaches Claude Code to write version-dependent code
using <code class="language-plaintext highlighter-rouge">NextRails.next?</code>, configures CI to test against both dependency sets, and cleans up
dual-boot code after the upgrade is complete.</p>

<p>While most commonly used for Rails upgrades, it works equally well for upgrading Ruby versions
or any core dependency like <code class="language-plaintext highlighter-rouge">sidekiq</code> or <code class="language-plaintext highlighter-rouge">devise</code>.</p>

<p>You can find the source code for this open source skill under the OmbuLabs.ai organization:
<a href="https://github.com/ombulabs/claude-code_dual-boot-skill">github.com/ombulabs/claude-code_dual-boot-skill</a></p>

<h3 id="the-rails-defaults-skill">The Rails Defaults Skill</h3>

<p>This skill handles the incremental update of <code class="language-plaintext highlighter-rouge">config.load_defaults</code> by walking through each framework
config one at a time with tiered risk assessment (low-risk configs first, configs requiring
human review last).</p>

<p>You can find the source code for this open source skill under the OmbuLabs.ai organization:
<a href="https://github.com/ombulabs/claude-code_rails-load-defaults-skill">github.com/ombulabs/claude-code_rails-load-defaults-skill</a></p>

<p>This separation keeps each skill focused and makes them independently useful. You can use the
dual-boot skill on its own for any dependency upgrade, or the load-defaults skill by itself to
align your framework defaults without doing a full Rails upgrade.</p>

<h2 id="built-on-8-years-of-published-knowledge">Built on 8+ Years of Published Knowledge</h2>

<p>These skills are not just a wrapper around the official Rails upgrade guides.</p>

<p>They incorporate the specific patterns, edge cases, and hard-won lessons from our
<a href="https://www.fastruby.io/blog/rails/upgrade/rails-upgrade-series.html">Rails Upgrade Series</a>,
which covers every upgrade path from Rails 2.3 to 8.1.</p>

<p>It also draws from our ebook <a href="https://www.fastruby.io/">The Complete Guide to Upgrade Rails</a>,
which documents the full FastRuby.io upgrade methodology.</p>

<p>The version-specific guides in the skill include detection patterns for breaking changes,
before/after code examples, and difficulty ratings based on what we have seen across hundreds
of real-world upgrades.</p>

<h2 id="installation">Installation</h2>

<p>Here is a quick guide for installing these skills in your local environment:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. The dual-boot skill</span>
git clone https://github.com/ombulabs/claude-code_dual-boot-skill.git
<span class="nb">cp</span> <span class="nt">-r</span> claude-code_dual-boot-skill/dual-boot ~/.claude/skills/

<span class="c"># 2. The load-defaults skill</span>
git clone https://github.com/ombulabs/claude-code_rails-load-defaults-skill.git
<span class="nb">cp</span> <span class="nt">-r</span> claude-code_rails-load-defaults-skill/rails-load-defaults ~/.claude/skills/

<span class="c"># 3. The upgrade skill (depends on the two above)</span>
git clone https://github.com/ombulabs/claude-code_rails-upgrade-skill.git
<span class="nb">cp</span> <span class="nt">-r</span> claude-code_rails-upgrade-skill/rails-upgrade ~/.claude/skills/
</code></pre></div></div>

<h2 id="usage">Usage</h2>

<p>Navigate to your Rails application directory and fire up Claude Code in your terminal. You
will see three new slash commands:</p>

<p><img src="/blog/assets/images/claude-code-skills-to-upgrade-rails-dual-boot-and-load-defaults.png" alt="Claude Code Rails Upgrade Skills in your terminal" /></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/rails-upgrade</code> – The orchestrator skill to upgrade Rails</li>
  <li><code class="language-plaintext highlighter-rouge">/rails-load-defaults</code> – The <code class="language-plaintext highlighter-rouge">load_defaults</code> enforcer</li>
  <li><code class="language-plaintext highlighter-rouge">/dual-boot</code> – The dual boot skill to set up your project for dual booting</li>
</ul>

<blockquote>
  <p>All of these commands can be called with or without an instruction. As a general rule, I recommend
you provide some context to the slash command, so that Claude can be more efficient (and <strong>less expensive!</strong>)</p>
</blockquote>

<p>If you call the Rails upgrade skill it will:</p>

<ol>
  <li>Run your test suite to establish a baseline</li>
  <li>Keep track of deprecation warnings</li>
  <li>Address deprecation warnings</li>
  <li>Verify your <code class="language-plaintext highlighter-rouge">load_defaults</code> is aligned with your current Rails version</li>
  <li>Detect breaking changes in your codebase</li>
  <li>Generate a comprehensive upgrade report with real code examples from your project</li>
  <li>Ask for input on next steps</li>
</ol>

<h2 id="whats-next">What’s Next?</h2>

<p>I would love to see teams use these skills in their next Ruby or Rails upgrade project. The more
people who use it (and extend it!), the more issues we can fix, and the more effective Claude
can be at upgrading applications.</p>

<p><strong>If you want to try it, the installation takes less than a minute.</strong></p>

<p>Please give it a try and hit me up on <a href="https://github.com/etagwerker">GitHub</a> or
<a href="https://bsky.app/profile/etagwerker.bsky.social">Bluesky</a> if you find any problems.</p>

<h2 id="contributing">Contributing</h2>

<p>We welcome contributions. If you have encountered edge cases in your own upgrades, found
incorrect detection patterns, or want to add support for new Rails versions, please open
an issue or submit a pull request:</p>

<ul>
  <li><a href="https://github.com/ombulabs/claude-code_rails-upgrade-skill">claude-code_rails-upgrade-skill</a></li>
  <li><a href="https://github.com/ombulabs/claude-code_dual-boot-skill">claude-code_dual-boot-skill</a></li>
  <li><a href="https://github.com/ombulabs/claude-code_rails-load-defaults-skill">claude-code_rails-load-defaults-skill</a></li>
</ul>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>AI is changing how we write and maintain software, but it still needs domain expertise to handle
complex tasks like Rails upgrades well.</p>

<p>By open sourcing this skill, we are giving the community access to the same methodology we use
with our clients, built on 60,000+ hours of real upgrade experience.</p>

<p>If you really don’t have the time to upgrade, I get it. Feel free to reach out,
<a href="https://www.fastruby.io/our-services">we are always here to help you upgrade Rails</a>. 🚀</p>]]></content><author><name>etagwerker</name></author><category term="upgrades" /><summary type="html"><![CDATA[An Open Source Claude Code skill based on our 60,000+ hours of Rails upgrade experience. Learn how our opinionated methodology teaches Claude Code to upgrade Rails, fast.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/open-source-claude-code-skills-to-upgrade-rails.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/open-source-claude-code-skills-to-upgrade-rails.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why Reliable Monthly Maintenance Became Non-Negotiable for a Solo SaaS Founder</title><link href="https://www.fastruby.io/blog/reliable-rails-maintenance-for-solo-saas-founders.html" rel="alternate" type="text/html" title="Why Reliable Monthly Maintenance Became Non-Negotiable for a Solo SaaS Founder" /><published>2026-03-13T05:15:00-04:00</published><updated>2026-03-13T05:15:00-04:00</updated><id>https://www.fastruby.io/blog/reliable-rails-maintenance-for-solo-saas-founders</id><content type="html" xml:base="https://www.fastruby.io/blog/reliable-rails-maintenance-for-solo-saas-founders.html"><![CDATA[<p>For the past twenty years, <a href="https://www.titleleaf.com/">TitleLeaf</a> has been helping educational book publishers maintain and share their content with industry partners such as reviewers, wholesalers, and distributors. Its robust content management system and handcrafted digital solutions simplify the process of sharing metadata, digital assets, and marketing material for the book industry.</p>

<h2 id="here-was-the-situation">Here was the situation</h2>

<p>Tim Peterson, Founder of TitleLeaf, is a solopreneur balancing the challenges of entrepreneurship with a flexible lifestyle. One of his dreams is to have more time and freedom to do what he enjoys most—skiing and biking in the mountains of Northern California while still running his company as efficiently as possible.</p>

<p>Peterson’s goal is to build an ever-evolving, feature-rich SaaS service and share his domain knowledge with customers without having to manage a team or chase technical updates:</p>

<blockquote>
  <p>“I got fed up chasing contractors and micromanaging. I tried hiring and outsourcing, but nothing was the right fit. I just wanted reliable maintenance so I didn’t have to worry about it.”</p>
</blockquote>

<!--more-->

<h2 id="here-is-how-it-evolved">Here is how it evolved</h2>

<p>In 2023, Peterson reached out to us for help and was particularly interested in our Bonsai service, our signature <a href="https://www.fastruby.io/blog/monthly-maintenance-services.html">fixed-cost monthly maintenance plan for Ruby applications</a> that helps alleviate technical debt. He was hoping we could provide the kind of hands-off service he was looking for.</p>

<blockquote>
  <p>“I felt like I was behind the curve on technology and was getting overwhelmed. I decided to try FastRuby.io’s Bonsai Service on a whim, and
the relationship has really worked out. I wanted them to focus on maintenance and keeping everything up-to-date, but then I started to lean
on them for other technical issues and features that I wanted to implement,” Peterson explained.</p>
</blockquote>

<p>The TitleLeaf engagement started with our monthly maintenance service. This included updating the codebase, patching security issues, and ensuring
server compatibility remained solid. TitleLeaf was lagging behind on a few Rails versions, and we reduced their
<a href="/blog/tags/technical-debt">technical debt</a> without disrupting his publishers or workflows. Peterson asked us to tackle a few other projects,
such as handling PCI compliance and updates to the new APIs.</p>

<blockquote>
  <p>“Our publishers needed to jump through PCI compliance hoops for the school districts that are buying the products. I was able to lean on FastRuby.io to handle the compliance and API updates. This was a huge help, as these are essential systems to an e-commerce platform. FastRuby.io made sure it was a smooth transition,” stated Peterson.</p>
</blockquote>

<p>At one point, he was looking for more ways to reduce his workload and time interacting with clients by creating a knowledge base they could lean on. He asked for our help, and we decided to give it a try to see if we could handle the helpdesk interactions. However, we found that Peterson’s knowledge wasn’t easily transferable—the solution was U.S. specific (i.e., the market and understanding how school libraries operate) with very domain-specific client needs.</p>

<blockquote>
  <p>“Even though I tried to put everything into a knowledge base, there was too much inside my head, so I still had to interact with clients. I met with the FastRuby.io team every month, and at one point, we all laughed because we were sensing the exact same thing; it wasn’t working. Even though the test didn’t work, the realization that they were willing to try and also to accept when things don’t work was wonderful,” Peterson noted.</p>
</blockquote>

<h2 id="heres-how-we-approached-the-engagement">Here’s how we approached the engagement</h2>

<p>From day one, we knew that Peterson wanted a relationship built on minimal friction, mutual trust, and clear communication. Our goal was to
make sure that we made consistent progress towards the priorities he set with a team he could rely on.</p>

<p>We approached the upgrades in different stages by updating the core parts of the application, enabling him to make use of new features. Peterson also asked us to conduct internal QA on our work and then deploy it to a staging environment, where he could then take it to production.</p>

<p>Some months are light, and some are more intensive. We increase or decrease the workload based on need, and that gives TitleLeaf both flexibility and reliability. This allows Peterson to focus on features instead of technical debt with the peace of mind knowing that updated dependencies are taken care of.</p>

<blockquote>
  <p>“What really stands out to me is how little communication is needed. The Bonsai team sends regular reports, we huddle each month to set goals and
after a quick review, I can go off and do my job. FastRuby.io quietly works in the background and plays a pivotal role in my business. Their
services are as essential as hosting—a non-negotiable. I can’t imagine doing business without this partnership; it requires almost no oversight from
me.” he observed.</p>
</blockquote>

<h2 id="heres-the-outcome-of-our-work">Here’s the outcome of our work</h2>

<p>TitleLeaf’s code is healthy and up-to-date and critical systems are compliant and stable. This allows its clients to bid confidently on school
district contracts without worrying about technological red flags and gives Peterson the peace of mind that he was hoping for. The
upgrade rhythm is predictable and reliable, and this provides the space for him to experiment with new ideas or tweak something for a client.</p>

<p>Peterson pointed out that “sometimes being a one-person team is challenging, and burnout can be difficult to deal with. FastRuby.io is the steady baseline behind everything. They’ve simplified my life, taken to-do’s off my plate, and freed me up to focus on what’s important. They are central to how TitleLeaf runs, and I’d recommend them to anyone building or scaling a SaaS company.”</p>

<h2 id="see-the-value-we-delivered">See the value we delivered</h2>

<p>TitleLeaf needed reliability and sustainability. The real value was reducing what was on Peterson’s plate: the stress of falling behind,
the inability to launch new features, keeping security up-to-date, and the mental stress of having to wear every hat. Today, Peterson
can flex his workload, step away when he wants, focus on what intrigues him most, and dream about platform improvements instead of
putting out fires.</p>

<blockquote>
  <p>“You need these people. Whether you’re a solo founder or niche SaaS operator, FastRuby.io is an indispensable part of your team and organization,” said Peterson.</p>
</blockquote>

<h2 id="project-type">Project type</h2>

<ul>
  <li>Bonsai by FastRuby.io (signature fixed-cost, monthly maintenance service)</li>
  <li>Ruby and Rails Upgrades</li>
  <li>PCI Compliance</li>
  <li>Security Upgrades</li>
</ul>

<h2 id="who-we-are">Who we are</h2>

<p><a href="https://www.ombulabs.ai">OmbuLabs.ai is Philadelphia’s AI Consulting Boutique</a> and creators of <a href="https://www.fastruby.io/our-services">FastRuby.io</a>:
A set of productized services to remediate technical debt.</p>

<p>Specializing in custom AI and machine learning solutions, we help startups to Fortune 500 companies leverage their data to build and improve their
digital products. Founded in 2011 and headquartered in Philadelphia, Pennsylvania, our experienced and diverse team of software engineers is ready
to do whatever it takes to help your business grow. To learn more about our custom AI services: <a href="https://www.ombulabs.ai/our-services">AI Consulting Services by OmbuLabs.ai</a></p>

<p>FastRuby.io is a set of battle-tested productized services for your Ruby and Rails applications. We help companies gradually improve their security,
performance, technical debt, and software engineering best practices.</p>

<p>In Tim’s words:</p>

<blockquote>
  <p>“FastRuby.io works independently and reliably-I can trust their work without worrying that something will break. They take real ownership
of their commitments and deliver consistently. That level of dependability easily justifies the investment.”</p>
</blockquote>

<p>Slow &amp; steady maintenance work is as essential as paying your hosting bill:</p>

<blockquote>
  <p>“Their services are as essential as hosting—a non-negotiable. I can’t imagine doing business without this partnership; it requires almost no oversight from me.”</p>
</blockquote>

<p>Tim Peterson, Founder at TitleLeaf</p>

<p>To learn more about our services, read more about them over here:</p>

<ul>
  <li><a href="https://www.fastruby.io/monthly-rails-maintenance">Bonsai, Fixed-cost Monthly Rails Maintenance Services</a></li>
  <li><a href="https://www.fastruby.io/security-audit">Rails Security Audits with Pen Testing</a></li>
  <li><a href="https://www.fastruby.io/tune">Tune: Rails Performance Audits</a></li>
</ul>]]></content><author><name>etagwerker</name></author><category term="case-study" /><summary type="html"><![CDATA[Solo SaaS founder Tim Peterson found peace of mind outsourcing Rails upgrades, security, and PCI compliance to FastRuby.io's monthly maintenance service.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/titleleaf-case-study.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/titleleaf-case-study.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Upgrade Rails from 8.0 to 8.1</title><link href="https://www.fastruby.io/blog/upgrade-rails-8-0-to-8-1.html" rel="alternate" type="text/html" title="Upgrade Rails from 8.0 to 8.1" /><published>2026-03-06T17:43:58-05:00</published><updated>2026-03-06T17:43:58-05:00</updated><id>https://www.fastruby.io/blog/upgrade-rails-from-8-0-to-8-1</id><content type="html" xml:base="https://www.fastruby.io/blog/upgrade-rails-8-0-to-8-1.html"><![CDATA[<p><em>This article is part of our Upgrade Rails series. To see more of them, <a href="/blog/rails/upgrade/rails-upgrade-series.html">click here</a></em>.</p>

<p>This article will cover the most important aspects that you need to know to get
your <a href="http://rubyonrails.org/">Ruby on Rails</a> application from <a href="https://edgeguides.rubyonrails.org/8_0_release_notes.html">version 8.0</a> to <a href="https://edgeguides.rubyonrails.org/8_1_release_notes.html">version 8.1</a>.</p>

<!--more-->

<ol>
  <li><a href="#preparations">1. Preparations</a></li>
  <li><a href="#ruby-version">2. Ruby Version</a></li>
  <li><a href="#gems">3. Gems</a></li>
  <li><a href="#config-files">4. Config Files</a></li>
  <li><a href="#rails-guides">5. Rails Guides</a></li>
  <li><a href="#notable-new-features">6. Notable New Features</a></li>
  <li><a href="#application-code">7. Application Code</a>
    <ul>
      <li><a href="#railties">7.1 Railties</a></li>
      <li><a href="#action-pack">7.2 Action Pack</a></li>
      <li><a href="#active-record">7.3 Active Record</a></li>
      <li><a href="#active-storage">7.4 Active Storage</a></li>
      <li><a href="#active-support">7.5 Active Support</a></li>
      <li><a href="#active-job">7.6 Active Job</a></li>
    </ul>
  </li>
  <li><a href="#next-steps">8. Next Steps</a></li>
</ol>

<h3 id="preparations">1. Preparations</h3>

<p>Before beginning with the upgrade process, we have some recommended preparations:</p>

<ul>
  <li>Your Rails app should have the latest <a href="http://semver.org">patch version</a> before you move to the next major/minor version.</li>
  <li>You should have at least 80% test coverage. We do have an article for <a href="/blog/10-strategies-for-upgrading-ruby-or-rails-applications-with-low-test-coverage.html">strategies on upgrading with low test coverage</a> as well.</li>
  <li>Have a staging environment that is as similar as possible to production, so that proper QA can be completed.</li>
  <li>Check your Gemfile.lock for incompatibilities by using <a href="https://railsbump.org/">RailsBump</a>.</li>
  <li>Create a dual boot mechanism, the fastest way to do this is installing the handy gem <a href="https://github.com/fastruby/next_rails">next_rails</a>. Find out more about <a href="/blog/upgrade-rails/dual-boot/dual-boot-with-rails-6-0-beta.html">how and why to dual boot</a>.</li>
  <li>To learn more about dual booting with non-backwards compatible changes you can refer to our <a href="/blog/rails/upgrade/dual-booting-with-conditionals.html">Solving Dual Booting Issues when Changes aren’t Backwards Compatible</a> article.</li>
</ul>

<p>For full details check out our article on <a href="/blog/rails/upgrade/prepare-for-rails-upgrade.html">How to Prepare Your App for a Rails Upgrade</a>.</p>

<h3 id="ruby-version">2. Ruby Version</h3>

<p>For the Rails 8.1 release, <a href="https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/">Ruby 3.2</a> is the required minimum version.</p>

<p>Check out our <a href="/blog/ruby/rails/versions/compatibility-table.html">Ruby &amp; Rails Compatibility Table</a> to see all the required Ruby versions across all Rails versions.</p>

<h3 id="gems">3. Gems</h3>

<p>Make sure you check the GitHub page of the gems currently installed in your application for more information about compatibility with Rails 8.1. If you are the maintainer of the gem, you’ll need to make sure it supports Rails 8.1.
A great tool to checkout gems compatibility is <a href="https://railsbump.org/">RailsBump</a>.
We also encourage you to use the <a href="https://github.com/fastruby/next_rails"><code class="language-plaintext highlighter-rouge">next-rails</code></a> gem to run <code class="language-plaintext highlighter-rouge">bundle_report outdated</code> for more information on gems that will require an update. Check out our article on <a href="/blog/next-rails-gem.html">The Next Rails Gem</a> to learn more.</p>

<h3 id="config-files">4. Config Files</h3>

<p>Rails includes the <code class="language-plaintext highlighter-rouge">rails app:update</code> <a href="https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#the-update-task">task</a>.
You can use this task as a guideline as explained thoroughly by this <a href="http://thomasleecopeland.com/2015/08/06/running-rails-update.html">Revisiting rails:update</a> blog post.</p>

<p>As an alternative, check out <a href="https://railsdiff.org/8.0.0/8.1.0">RailsDiff</a>, which provides an overview of the changes in a basic Rails app between 8.0.x and 8.1.x (or any other source/target versions).</p>

<h3 id="rails-guides">5. Rails Guides</h3>

<p>It is important to check through the official <a href="https://edgeguides.rubyonrails.org/upgrading_ruby_on_rails.html#upgrading-from-rails-8-0-to-rails-8-1">Rails Guides</a> and follow any of the steps necessary for your application.</p>

<h3 id="notable-new-features">6. Notable New Features</h3>

<p><strong>Active Job Continuations</strong>: Long-running jobs can now be broken into discrete steps that allow execution to continue from the last completed step rather than the beginning after a restart. This is especially helpful when doing deploys with Kamal, which will only give job-running containers thirty seconds to shut down by default.</p>

<p><strong>Structured Event Reporting</strong>: The new Event Reporter provides a unified interface for producing structured events in Rails applications. Check out <a href="https://www.fastruby.io/blog/rails-event-notify.html">this article</a> on what that means for your Rails app.</p>

<p><strong>Local CI</strong>: Rails has added a default CI declaration DSL, which is defined in <code class="language-plaintext highlighter-rouge">config/ci.rb</code> and run by <code class="language-plaintext highlighter-rouge">bin/ci</code>. You can learn more about this <a href="https://www.fastruby.io/blog/rails-8-1-local-ci.html">here</a>.</p>

<p><strong>Mardown Rendering</strong>: Markdown has become the lingua franca of AI, and Rails has embraced this adoption by making it easier to respond to markdown requests and render them directly.</p>

<p><strong>Command-line Credentials Fetching</strong>: Kamal can now easily grab its secrets from the encrypted Rails credentials store for deploys. This makes it a low-fi alternative to external secret stores that only needs the master key available to work.</p>

<h3 id="application-code">7. Application Code</h3>

<p>If you have ignored deprecation warnings on past version jumps, and haven’t stayed up to date with them you may find that you have issues with broken tests or broken parts of the application. If you have trouble figuring out why something is broken it may be because a deprecation is removed. The following is a list of the removals in Rails 8.1.</p>

<h5 id="railties">7.1 Railties</h5>

<ul>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">rails/console/methods.rb</code> file.</li>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">bin/rake stats</code> command.</li>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">STATS_DIRECTORIES</code>.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/v8.1.0/railties/CHANGELOG.md">changelog</a>.</li>
</ul>

<h5 id="action-pack">7.2 Action Pack</h5>

<ul>
  <li>Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser.</li>
  <li>Remove deprecated support for using semicolons as a query string separator.</li>
  <li>Remove deprecated support to a route to multiple paths.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/v8.1.0/actionpack/CHANGELOG.md">changelog</a>.</li>
</ul>

<h5 id="active-record">7.3 Active Record</h5>

<ul>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">:retries</code> option for the SQLite3 adapter.</li>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">:unsigned_float</code> and <code class="language-plaintext highlighter-rouge">:unsigned_decimal</code> column methods for MySQL.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/8-1-stable/activerecord/CHANGELOG.md">changelog</a>.</li>
</ul>

<h5 id="active-storage">7.4 Active Storage</h5>

<ul>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">:azure</code> storage service.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/8-1-stable/activestorage/CHANGELOG.md">changelog</a>.</li>
</ul>

<h5 id="active-support">7.5 Active Support</h5>

<ul>
  <li>Remove deprecated passing a Time object to <code class="language-plaintext highlighter-rouge">Time#since</code>.</li>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">Benchmark.ms</code> method. It is now defined in the <code class="language-plaintext highlighter-rouge">benchmark</code> gem.</li>
  <li>Remove deprecated addition for <code class="language-plaintext highlighter-rouge">Time</code> instances with <code class="language-plaintext highlighter-rouge">ActiveSupport::TimeWithZone</code>.</li>
  <li>Remove deprecated support for <code class="language-plaintext highlighter-rouge">to_time</code> to preserve the system local time. It will now always preserve the receiver timezone.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/8-1-stable/activesupport/CHANGELOG.md">changelog</a>.</li>
</ul>

<h5 id="active-job">7.6 Active Job</h5>

<ul>
  <li>Remove support to set <code class="language-plaintext highlighter-rouge">ActiveJob::Base.enqueue_after_transaction_commit</code> to <code class="language-plaintext highlighter-rouge">:never</code>, <code class="language-plaintext highlighter-rouge">:always</code> and <code class="language-plaintext highlighter-rouge">:default</code>.</li>
  <li>Remove deprecated <code class="language-plaintext highlighter-rouge">Rails.application.config.active_job.enqueue_after_transaction_commit</code>.</li>
  <li>Remove deprecated internal SuckerPunch adapter in favor of the adapter included with the <code class="language-plaintext highlighter-rouge">sucker_punch</code> gem.</li>
  <li>See full list of changes in the <a href="https://github.com/rails/rails/blob/8-1-stable/activejob/CHANGELOG.md">changelog</a>.</li>
</ul>

<h3 id="next-steps">8. Next Steps</h3>

<p>If you successfully followed all of these steps, you should now be running Rails 8.1!</p>

<p>If you find yourself in need of assistance or your team doesn’t have the time to tackle these challenges, you can always contact us. <a href="/#contactus">Our experts are here to help you navigate the upgrade process and ensure your Rails application continues to thrive.</a></p>

<p>Download our free eBook: <a href="https://www.fastruby.io/">The Complete Guide to Upgrade Rails</a>.</p>]]></content><author><name>aisayo</name></author><category term="upgrades" /><summary type="html"><![CDATA[How to upgrade Ruby on Rails from 8.0 to 8.1, including the deprecations, required configurations, application code changes, and webpacker API changes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/upgrade-rails-8-0-to-8-1.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/upgrade-rails-8-0-to-8-1.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From 40 Minutes to 4 with Tests Parallelization</title><link href="https://www.fastruby.io/blog/speed-up-tests-from-40-to-4-minutes.html" rel="alternate" type="text/html" title="From 40 Minutes to 4 with Tests Parallelization" /><published>2026-03-02T19:04:58-05:00</published><updated>2026-03-02T19:04:58-05:00</updated><id>https://www.fastruby.io/blog/speed-up-tests-from-40-to-4-minutes</id><content type="html" xml:base="https://www.fastruby.io/blog/speed-up-tests-from-40-to-4-minutes.html"><![CDATA[<p>Last month, we finished a big upgrade for a client. The client had 2 main pain points for their app. The first one was that they were using Rails 2.3 LTS with Ruby 2.5. The next big issue was that the test suite took 40 minutes to run, blocking engineers from merging code into the main branch, and also slowing down the whole feedback loop for every code change.</p>

<p>After finishing the upgrade (we got the application to Rails 8.1.1 and Ruby 3.4.7), we focused our attention on improving the test’s speed and we reduced the time it took to run the whole test suite (over 10k tests) from 40 minutes to around 4!</p>

<!--more-->

<h2 id="the-different-causes-of-slow-tests">The Different Causes of Slow Tests</h2>

<h3 id="the-test-runner">The Test Runner</h3>

<p>The most popular test runners for Rails are currently <a href="https://www.minite.st">Minitest</a> and <a href="https://rspec.info">RSpec</a>, and this application was using <a href="https://github.com/test-unit/test-unit">Test::Unit</a>. Minitest is the default testing framework in new Rails applications and it’s known to be <a href="https://www.minite.st/comparisons.html">the fastest of the 3</a>.</p>

<p>This, along with other improvements from Ruby and Rails upgrades, already proved beneficial… not on the total run time, but we did notice that the time it took to start the tests improved significantly: when trying to run a single file or a single method, it used to take around 30 seconds booting the app, and by the time we finished the upgrades and the migration to Minitest, this was reduced to a few seconds.</p>

<p>Trying to run a single test inside a file took even longer (we don’t know the exact cause, but it was really slow), when now it takes a similar time to boot the app for the test either with a single file or with a file and a line number specified.</p>

<p>This had a huge impact on the feedback loop when working locally, since running a few specific test files was already better.</p>

<p>But we still had to improve the test suite speed and not just the boot time.</p>

<h3 id="factories-vs-fixtures">Factories vs Fixtures</h3>

<p>This application was already using mostly fixtures for the tests data, with a few factories here and there for some dynamic data. We didn’t have that much to change here, but it’s worth mentioning here.</p>

<p>When we work with factories (like using the <a href="https://github.com/thoughtbot/factory_bot"><code class="language-plaintext highlighter-rouge">factory_bot</code> gem</a>), if our tests keep persisting similar objects, we would be performing the same work many many times, adding up the more tests we have. Imagine a test suite that creates a new User record for every test for authentication purposes, always the same, with database writes every single time.</p>

<p>We can improve those cases with fixtures, records that get created in the database at the beginning of the test suite that can be reused by all tests, reducing the number of database writes and code executed. This always shows a positive improvement in tests speed, and factories can still be used for exceptional cases or more dynamic data that still requires writing to the db. We can also go “all in” and use fixtures for almost everything, but it can be hard to keep them clean and organized.</p>

<p>There are some trade-offs with fixtures though:</p>

<ul>
  <li>the data is defined in a fixtures file instead of close to the test: this makes it a bit harder to understand what’s the specific state required for a test</li>
  <li>fixtures can be invalid by mistake: since Rails simply inserts the data in the database, if our fixture is not correct, we’ll have an invalid record when reading it from the database (this is really common when models change over time and people forget to update the fixtures to reflect those changes)</li>
  <li>complex associations are hard to setup and maintain: when the fixtures are loaded, Rails will insert records in bulks in the db, without setting up proper associations or join models, and without running callbacks, so setting associations means configuring all the records properly ahead of time, instead of letting Rails handle all that complexity</li>
  <li>fixtures that are only used once can make things hard to maintain with little performance gain</li>
</ul>

<p>Our experience is that it’s better to combine both Fixtures and Factories in a test suite and use them when they make more sense: use fixtures to generate data that is created constantly by tests, and keep factories for data that is not shared that much or that is really hard to set up properly.</p>

<h3 id="slow-tests">Slow Tests</h3>

<p>Sometimes, the reason for a test suite to be slow are specific tests. We have seen in the past some ideas to speed up tests that were correct, changing a known slow pattern in a single file, but for a test that was already fast and the slowness of the pattern was not noticeable in practice.</p>

<p>Always, when talking about performance, it’s important to measure to find where to focus our efforts. With both RSpec and Minitest, the <code class="language-plaintext highlighter-rouge">--profile</code> flag can be passed in the command line to get a list of the top 10 slowest tests. With that information we can be more effective and really tackle the tests that will have the highest impact.</p>

<p>With this command we identified some tests that had a <code class="language-plaintext highlighter-rouge">sleep(1)</code> call (for historical reasons and some race conditions from when the application used threads) and we could remove them by addressing the actual problem.</p>

<h2 id="tests-parallelization">Tests Parallelization</h2>

<p>One of our goals during the upgrade was to migrate to Minitest, not only because we knew it’s faster, but also because we could remove the <code class="language-plaintext highlighter-rouge">test-unit</code> and <code class="language-plaintext highlighter-rouge">test-unit-rails</code> gems, and, more importantly, we knew we would be able to use Rails’ parallelization feature to split the workload of running the tests and significantly reduce the time.</p>

<h3 id="randomized-order">Randomized Order</h3>

<p>From the beginning of the project, the 10 thousand tests were executed in a non-random order. One of the first things we had to solve was making sure the tests ran randomized. This was not essential for the actual upgrades, but it’s a general good practice and also it was really important for the parallelization in the future, as adding/removing tests or having different numbers of cores was going to have the side effect of having the tests grouped differently over time and with different computers.</p>

<p>These issues typically involved tests changing something and not reverting it back at the end, or some that actually depended on these changes that “leaked” from other tests that ran before. We were already using transactional tests so the data in the database was not a problem, but that won’t solve issues caused by, for example, changing <code class="language-plaintext highlighter-rouge">I18n.locale</code> in a test and not changing it back to the original value.</p>

<p>There’s no single way of solving these issues, as they can be anything: a locale change, a file being deleted, a class constant override, some code loaded explicitly in a test, etc. What helps a lot is using the <code class="language-plaintext highlighter-rouge">--seed</code> flag to be able to run tests over and over in the same order.</p>

<p>Once we had the test suite running in randomized order, we could finally enable parallelization: a simple <code class="language-plaintext highlighter-rouge">parallelize(workers: :number_of_processors)</code> in the <code class="language-plaintext highlighter-rouge">ActiveSupport::TestCase</code> class in our <code class="language-plaintext highlighter-rouge">test_helper.rb</code> file. We ran the tests and now there’s a line that says <code class="language-plaintext highlighter-rouge">Running 10438 tests in parallel using 14 processes</code> and that’s it! … in theory.</p>

<h3 id="race-conditions">Race Conditions</h3>

<p>The theory is not wrong though, Rails was running the tests in parallel, but we quickly found out more problems, not just with randomized order for new combinations we didn’t catch before, but also with tests with side-effects that would conflict with other tests running at the same time, a kind of race condition.</p>

<blockquote>
  <p>Something that we noticed right away was that the test suite total run time changed from over 40 minutes to less than 4! but we could not be confident yet about these results, because we had so many tests failing from these race conditions that many tests were not running all the way to the end, making them take way less time than they should. But it was a good first teaser of the speed improvement!</p>
</blockquote>

<p>We found that the main culprit for these race conditions was the heavy use of temporary files by this particular application, and how the tests were creating and deleting files and folders all the time. If two tests are adding files to (or clearing) the <code class="language-plaintext highlighter-rouge">tmp/export</code> folder (as an example) before or after they run, when they run in parallel, both will try to remove a file the other test needs.</p>

<p>The solutions were too specific for the client’s application, but there were a few common patterns:</p>

<ul>
  <li>hardcoded directories: either directly in the code or as class constants</li>
  <li>aggressive deletion of files: instead of deleting the files just created, it was clearing complete directories</li>
  <li>pessimistic deletion of files: clearing complete directories in case a file was problematic (instead of relying on proper cleanup of previous tests)</li>
  <li>tests modifying the same file: with more than one test writing and reading a single file before reverting the changes</li>
</ul>

<p>This was the most time-consuming process, because, combined with the randomized order and the fact that there’s no warranty 2 tests are going to run at the same time, it was not always easy to find the correct combinations to reproduce these issues (or even find them in the first place!). We had to run the tests over and over and over again for days while fixing these issues.</p>

<p>But now, running the tests was not a problem, it was not tedious, we could run the whole test suite (the 10 thousand tests) in around 4 minutes!</p>

<h3 id="no-minitest-no-problem">No Minitest? No Problem!</h3>

<p>Since the application was using Test::Unit, it was fairly easy to migrate to Minitest, a lot of the syntax is similar, the assertion patterns are similar (even though with different assertion methods), and we knew we wanted to use the built-in parallelization of Rails. But this is not the case for all clients, when they use RSpec or Cucumber we can’t really use Rails’ parallelization, so we have to use alternative gems. Some tools we found over time that could help are:</p>

<ul>
  <li><a href="https://github.com/briandunn/flatware">flatware</a> (works with RSpec and Cucumber)</li>
  <li><a href="https://github.com/grosser/parallel_tests">parallel_tests</a> (works with RSpec, Cucumber, and Test::Unit)</li>
  <li><a href="https://github.com/serpapi/turbo_tests">turbo_tests</a> (only RSpec, uses parallel_tests internally but with better output at the end)</li>
  <li><a href="https://rubygems.org/gems/knapsack_pro">knapsack_pro</a> (integrates with the CI infra to use more than the CPUs of the current machine)</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>This change was one of our objectives since the beginning of the project, the first priority was always to upgrade Ruby and Rails versions, but keeping in mind that we wanted to enable parallelization down the road right after, so we took small steps in that direction during the whole process. Rails’ parallelization feature was introduced in Rails 6 so it can be enabled earlier if that’s the main priority for your app.</p>

<p>All the upgrades and parallelization of the tests really improved the developer experience:</p>

<ul>
  <li>engineers can now use modern Ruby and Rails feature and remove old code and dependencies</li>
  <li>they can find better resources for new features</li>
  <li>all the Ruby and Rails speed improvements made the development faster, with better code loading, Ruby speed optimizations, and more</li>
  <li>the engineers could finally start running the tests locally if needed to not rely always on CI for small changes</li>
</ul>

<p>But the main 2 benefits of the parallelization translate both in release speed and infrastructure costs. Before, they could effectively only merge code into the main branch once every 40 minutes, while it can now happen after 5; and if existing PRs had conflicts, those PRs had to be updated and wait another 40 minutes before being ready for merge. This removed the biggest blocker for engineers to quickly iterate. The whole test suite runs in around 4 minutes (some machines have more or less processors), and it’s not the bottleneck of the process anymore.</p>

<p>The other benefit relates to costs of the CI infrastructure: CI machines were underutilized for years. Each CI machine had between 14 and 16 processors, but, before parallelization, only one core was used to run the tests while the rest were doing practically nothing. This also led to adding more CI machines to speed up the queue of the PRs waiting for tests to run. Now that the machines are being fully used and for a shorter time, the fleet can be reduced, saving not only time but also costs. And since engineers can now run tests locally, this also reduced the number of elements in the CI queue, allowing the fleet to be even smaller.</p>

<p>Do you need help with your slow tests? <a href="/#contactus">We can take a look!</a></p>]]></content><author><name>arieljuod</name></author><category term="performance" /><summary type="html"><![CDATA[How we cut a test suite time from 40 minutes to 4 by upgrading Ruby/Rails, fixing slow tests, and enabling parallelization, dramatically improving developer experience and CI costs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/from-40-minutes-to-4-with-parallelization.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/from-40-minutes-to-4-with-parallelization.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>