Propshaft vs Sprockets: Complete Rails 8 Asset Pipeline Migration Guide

Propshaft vs Sprockets comparison for Rails 8 asset pipeline migration

Your developers spend time waiting for builds instead of shipping features. This guide helps them fix that — deploys get faster and your team ships more often. If your dev team mentions “the asset pipeline” as a pain point, send them this post.

Your Sprockets precompile takes 60 seconds. You change one CSS variable. Sixty seconds again. Every deploy, every CI run, every developer on the team—waiting.

Propshaft replaces Sprockets as the default asset pipeline in Rails 8, and the difference is dramatic: in our experience, build times drop from 45-60 seconds to under 5 seconds for medium-sized apps. But Propshaft isn’t a drop-in replacement. It removes features you might depend on—Sass compilation, CoffeeScript transpilation, asset concatenation. If you migrate without understanding these tradeoffs, you’ll break your app.

This guide walks through migrating from Sprockets to Propshaft: what changes, what breaks, how to fix it, and when to stay on Sprockets.

The Problem with Sprockets in Modern Rails Applications #

Sprockets was designed in an era when HTTP/1.1 connection limits made asset concatenation essential for performance. Bundling all JavaScript and CSS into single files reduced the number of HTTP requests, significantly improving page load times. However, modern web development has evolved beyond these constraints.

How HTTP/2 Changed the Game #

HTTP/2 introduced multiplexing, allowing multiple asset requests over a single connection without performance penalties. The old practice of concatenating all assets into massive application.js and application.css files now creates problems:

When you change one line of CSS, your users re-download the entire bundle because the digest changes for everything. New visitors download all your JavaScript and CSS upfront, even if they only visit one page. Your developers wait through compilation pipelines that resolve dependencies across hundreds of files. And everyone on the team carries the cognitive load of Sprockets directives, manifests, and precompilation steps that exist to solve a problem HTTP/2 already solved.

Real-World Performance Impact #

Consider a typical Rails application with Sprockets:

# app/assets/config/manifest.js
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js

This manifest triggers a multi-stage compilation process. Sprockets scans your entire directory tree, then walks every require directive across hundreds of files to resolve dependencies. It concatenates everything into massive bundles, runs compression over the whole result, and finally generates fingerprinted filenames.

In our experience, this process takes 45-60 seconds on moderate-sized applications with 200+ assets. For larger applications, precompilation can exceed 2 minutes, dragging down every deploy and CI run.

The Maintenance Burden #

Sprockets requires ongoing maintenance that distracts from business value delivery:

# config/initializers/assets.rb - Typical Sprockets configuration
Rails.application.config.assets.version = '1.0'
Rails.application.config.assets.precompile += %w( admin.js admin.css )
Rails.application.config.assets.precompile += %w( mobile/*.js mobile/*.css )
Rails.application.config.assets.paths << Rails.root.join('app', 'assets', 'fonts')
Rails.application.config.assets.paths << Rails.root.join('vendor', 'assets', 'javascripts')
Rails.application.config.assets.css_compressor = :sass
Rails.application.config.assets.js_compressor = :terser

This configuration grows increasingly complex as applications scale, requiring specialized knowledge to maintain and debug. If you’re also managing Hotwire and Turbo integration , the asset pipeline complexity compounds.

Understanding Propshaft’s Modern Approach #

Sprockets optimized for a world of HTTP/1.1 connection limits. That world is gone. Propshaft skips the parts you no longer need and simplifies what’s left.

Core Philosophy: Simplicity Over Complexity #

Propshaft copies your files to public/, adds digest fingerprints, and gets out of the way. It serves each file individually so HTTP/2 multiplexing can do its job. It skips compilation entirely and lets external tools like Dart Sass or esbuild handle that if you need it. Import maps and ES6 modules replace Sprockets’ dependency resolution. And because Propshaft follows sensible defaults, most apps need almost zero configuration.

# The entire Propshaft configuration for most applications
# config/application.rb
config.assets.pipeline = :propshaft

That’s it. No manifest files, no precompile arrays, no complex path configuration.

Architecture Comparison #

Sprockets Architecture #

Source Assets
  ↓
  → Sprockets Processor
      ↓
      → Dependency Scanner
      → Concatenator
      → Compressor
      → Digest Generator
  ↓
Compiled Bundle (application.js/css)
  ↓
Public Assets Directory

Propshaft Architecture #

Source Assets
  ↓
  → Propshaft Processor
      ↓
      → Copy Files
      → Generate Digests
  ↓
Public Assets Directory (individual files)

Where Sprockets runs five stages that each can fail and each need debugging, Propshaft runs two. We had a client whose Sprockets concatenation step silently dropped a vendor file on every third deploy. That category of bug disappears when you stop concatenating.

How Propshaft Handles Common Asset Patterns #

CSS Management with Propshaft #

/* app/assets/stylesheets/application.css */
/* Propshaft doesn't process @import directives */
/* Instead, use link tags in your layout: */
<!-- app/views/layouts/application.html.erb -->
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "components/nav", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "components/footer", "data-turbo-track": "reload" %>

HTTP/2 multiplexing makes multiple stylesheet requests more efficient than under HTTP/1.1, while providing better cache granularity. That said, HTTP/2 doesn’t eliminate all overhead from many small requests—benchmark your own app to confirm the tradeoff works for your asset count and sizes.

JavaScript Management with Import Maps #

# config/importmap.rb
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true

# Pin local JavaScript modules
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/components", under: "components"
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"
import "./components"

Import maps provide native browser module loading without build steps, transpilation, or bundlers.

Image Asset Processing #

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
  end
end
<!-- app/views/users/show.html.erb -->
<%= image_tag user.avatar.variant(:thumb) %>

Propshaft focuses on serving static images efficiently, while Active Storage handles dynamic image processing.

Performance Characteristics #

Build Time Comparison #

For a medium-sized Rails application (200+ asset files):

# Sprockets precompilation
$ time bin/rails assets:precompile
...
real    0m48.742s
user    0m42.315s
sys     0m6.427s

# Propshaft asset compilation
$ time bin/rails assets:precompile
...
real    0m4.128s
user    0m2.845s
sys     0m1.283s

In our testing, that’s a 92% reduction in build time. If you’re deploying with Kamal , faster asset compilation means faster deploys across the board.

Memory Usage During Compilation #

# Memory profiling during asset compilation
require 'objspace'

# Sprockets compilation
ObjectSpace.memsize_of_all
# => 425MB peak memory usage

# Propshaft compilation
ObjectSpace.memsize_of_all
# => 87MB peak memory usage

80% lower memory usage enables efficient compilation in memory-constrained environments like CI/CD pipelines.

Runtime Performance #

Our benchmarks comparing asset delivery with HTTP/2:

Page Load with Sprockets (single bundled file):
  - First visit: 2.4s (download 450KB bundle)
  - Cache hit: 0.2s
  - Cache miss (after change): 2.4s (re-download entire bundle)

Page Load with Propshaft (individual files, HTTP/2 multiplexing):
  - First visit: 1.8s (parallel download of 25 files)
  - Cache hit: 0.2s
  - Cache miss (after change): 0.4s (re-download only changed files)

In our testing, individual file serving with HTTP/2 multiplexing provided roughly 25% faster initial loads and significantly faster cache-miss scenarios when assets change—because only the changed file gets re-downloaded. Your results will vary based on asset count, file sizes, and CDN configuration. Benchmark your own app before and after migration to confirm the gains.

What Propshaft Doesn’t Do #

Understanding Propshaft’s limitations is crucial for migration planning:

No Sass/SCSS Compilation #

// This won't compile in Propshaft
.button {
  $primary-color: #007bff;
  background: $primary-color;

  &:hover {
    background: darken($primary-color, 10%);
  }
}

Solution: Use CSS preprocessor gems or build tools:

# Gemfile
gem 'sassc-rails'  # Compile Sass outside Propshaft
gem 'tailwindcss-rails'  # Use Tailwind for utility-first CSS

No CoffeeScript/TypeScript Transpilation #

# app/assets/javascripts/example.coffee
# Won't compile in Propshaft
class Example
  constructor: (@name) ->
    console.log "Hello, #{@name}"

Solution: Migrate to modern JavaScript or use external build tools:

// app/javascript/example.js
class Example {
  constructor(name) {
    this.name = name;
    console.log(`Hello, ${this.name}`);
  }
}

No Asset Concatenation #

//= require jquery
//= require jquery_ujs
//= require_tree .

These Sprockets directives don’t work in Propshaft.

Solution: Use import maps or external bundlers for dependency management.

No Automatic Minification #

Propshaft doesn’t minify JavaScript or CSS during compilation.

Solution: Pre-minify vendor assets or use gems like terser for build-time minification:

# lib/tasks/minify.rake
namespace :assets do
  desc "Minify JavaScript and CSS"
  task minify: :environment do
    require 'terser'
    # Custom minification logic
  end
end

When NOT to Migrate to Propshaft #

Propshaft isn’t the right move for every Rails app. Stay on Sprockets if:

  • Your app relies heavily on Sass/SCSS features like mixins, functions, and @extend across dozens of files. You’ll need to add sassc-rails or dartsass-rails as a separate build step, and if Sass is deeply embedded in your workflow, the migration cost may not justify the build-time savings.
  • You’re on HTTP/1.1 and can’t upgrade. Propshaft’s individual-file serving strategy assumes HTTP/2 multiplexing. Without it, you’ll make more round trips and likely see worse performance than a concatenated Sprockets bundle.
  • You depend on Sprockets plugins (sprockets-es6, sprockets-bumble_d, custom processors). Propshaft has no plugin system—you’ll need to replace each one with an external build tool.
  • Your team is mid-feature-sprint and the asset pipeline works fine. Migration is a distraction when Sprockets isn’t causing actual pain. Ship the feature first.
  • You’re running Rails 6 or earlier. Propshaft targets Rails 7+. Upgrade Rails first, stabilize, then consider asset pipeline changes.

The honest test: if bin/rails assets:precompile finishes in under 10 seconds and your deploy pipeline isn’t bottlenecked on assets, Propshaft migration is a nice-to-have, not a must-have.

Step-by-Step Migration from Sprockets to Propshaft #

Migrating from Sprockets to Propshaft requires systematic planning. If you’re also upgrading Rails versions, handle that first—see Rails performance optimization patterns for the broader picture.

Phase 1: Pre-Migration Assessment #

Inventory Your Current Asset Stack #

# Audit your current Sprockets configuration
$ grep -r "assets" config/
$ find app/assets -type f | wc -l
$ cat app/assets/config/manifest.js

Create a full inventory:

# lib/tasks/asset_audit.rake
namespace :assets do
  desc "Audit current asset configuration"
  task audit: :environment do
    puts "=== Asset Audit ==="
    puts "Sprockets version: #{Sprockets::VERSION}"
    puts "Asset paths: #{Rails.application.config.assets.paths}"
    puts "Precompiled assets: #{Rails.application.config.assets.precompile}"
    puts "\n=== File Inventory ==="

    asset_types = {
      javascript: Dir.glob("app/assets/javascripts/**/*.{js,coffee}").count,
      stylesheets: Dir.glob("app/assets/stylesheets/**/*.{css,scss,sass}").count,
      images: Dir.glob("app/assets/images/**/*").count
    }

    asset_types.each { |type, count| puts "#{type}: #{count} files" }
  end
end

Identify Dependencies on Sprockets Features #

Search for Sprockets-specific syntax across your codebase:

# Find Sprockets directives
$ grep -r "//=" app/assets/javascripts/
$ grep -r "*=" app/assets/stylesheets/

# Find CoffeeScript files
$ find app/assets -name "*.coffee"

# Find Sass/SCSS files
$ find app/assets -name "*.scss" -o -name "*.sass"

# Check for ERB in assets
$ find app/assets -name "*.erb"

Document Migration Blockers #

Common blockers to address before migration:

  1. CoffeeScript usage: Requires conversion to modern JavaScript
  2. Sass/SCSS with complex features: May need preprocessing solution
  3. Asset gems: Verify Propshaft compatibility
  4. Custom Sprockets processors: Need alternative implementation
  5. Heavy use of require directives: Requires import map configuration

Phase 2: Preparing Your Application #

Update to Rails 7.1+ First #

Never migrate Sprockets → Propshaft while also upgrading Rails major versions:

# Ensure you're on Rails 7.1 or higher with Sprockets
$ bundle update rails
$ rails -v  # Should show 7.1.x or higher

Set Up Import Maps #

Install and configure import maps for JavaScript dependency management:

$ bin/rails importmap:install

This generates:

# config/importmap.rb
pin "application", preload: true
// app/javascript/application.js
// Entry point for the build script in your package.json
console.log("Hello from application.js")

Convert CoffeeScript to JavaScript #

If you have CoffeeScript files, convert them to modern JavaScript:

# Install conversion tool
$ npm install -g decaffeinate

# Convert all CoffeeScript files
$ find app/assets/javascripts -name "*.coffee" -exec decaffeinate {} \;

Example conversion:

# Before: app/assets/javascripts/users.coffee
class User
  constructor: (@name, @email) ->

  greet: ->
    "Hello, #{@name}"
// After: app/javascript/users.js
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Hello, ${this.name}`;
  }
}

Set Up CSS Preprocessing (If Needed) #

If using Sass/SCSS, ensure compilation happens before Propshaft:

# Gemfile
gem 'sassc-rails'  # Sass compilation
gem 'dartsass-rails'  # Alternative: Dart Sass for modern features

Configure CSS build process:

# package.json (if using Dart Sass via npm)
{
  "scripts": {
    "build:css": "sass ./app/assets/stylesheets:./app/assets/builds --no-source-map --load-path=node_modules"
  }
}
# config/application.rb
config.dartsass.builds = {
  "application.scss" => "application.css"
}

Phase 3: Switch to Propshaft #

Install Propshaft Gem #

# Gemfile
# Remove or comment out Sprockets
# gem 'sprockets-rails'

# Add Propshaft
gem 'propshaft'
$ bundle install

Update Application Configuration #

# config/application.rb
module YourApp
  class Application < Rails::Application
    # ...existing config...

    # Switch to Propshaft
    config.assets.pipeline = :propshaft
  end
end

Remove Sprockets-Specific Configuration #

# config/initializers/assets.rb
# DELETE these Sprockets-specific configurations:
# Rails.application.config.assets.version = '1.0'
# Rails.application.config.assets.precompile += %w( admin.js admin.css )
# Rails.application.config.assets.paths << ...
# Rails.application.config.assets.css_compressor = :sass
# Rails.application.config.assets.js_compressor = :terser

# Propshaft needs minimal configuration:
# (Usually nothing needed here)

Restructure Asset Directory #

Move JavaScript from app/assets/javascripts to app/javascript:

$ mkdir -p app/javascript
$ mv app/assets/javascripts/* app/javascript/
$ rm -rf app/assets/javascripts

Update stylesheet organization:

# Keep stylesheets in app/assets/stylesheets
# But remove Sprockets directives
/* app/assets/stylesheets/application.css */
/* BEFORE (Sprockets directives - remove these): */
/*
 *= require_tree .
 *= require_self
 */

/* AFTER (Plain CSS - or use link tags in layout): */
/* Global styles */

Convert Manifests to Import Maps #

# config/importmap.rb
# Pin application entry point
pin "application", preload: true

# Pin JavaScript dependencies
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true

# Pin all controllers
pin_all_from "app/javascript/controllers", under: "controllers"

# Pin third-party libraries (from CDN or vendor)
pin "jquery", to: "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"

Update View Helpers #

Update layout files to work with Propshaft:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Your App</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <!-- CSS: Link individual stylesheets -->
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

    <!-- JavaScript: Use import map -->
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

Phase 4: Testing and Validation #

Verify Asset Compilation #

# Precompile assets
$ RAILS_ENV=production bin/rails assets:precompile

# Check compiled assets
$ ls -lh public/assets/

# Verify digested filenames
$ ls public/assets/*.css
# application-abc123.css

# Test asset serving locally
$ RAILS_ENV=production bin/rails server
# Visit http://localhost:3000 and check browser console for asset errors

Test Asset Helper Methods #

# rails console
> helper.asset_path("application.css")
=> "/assets/application-abc123.css"

> helper.image_path("logo.png")
=> "/assets/logo-def456.png"

> helper.javascript_importmap_tags
# Should return import map script tags

Run Full Test Suite #

# Run system tests to verify asset loading
$ bin/rails test:system

# Check for missing asset errors in logs
$ grep "Asset.*not found" log/test.log

Performance Benchmarking #

Compare build times before and after migration:

# Clean assets
$ bin/rails assets:clobber

# Benchmark Propshaft compilation
$ time RAILS_ENV=production bin/rails assets:precompile

Our typical results:

  • Small apps (50 assets): 1-2 seconds (vs 10-15s with Sprockets)
  • Medium apps (200 assets): 3-5 seconds (vs 45-60s with Sprockets)
  • Large apps (500+ assets): 8-12 seconds (vs 2-3min with Sprockets)

Phase 5: Production Deployment #

Update Deployment Scripts #

# Ensure asset precompilation happens during deployment
# Capistrano example:

# config/deploy.rb
before 'deploy:assets:precompile', 'deploy:assets:clean'

namespace :deploy do
  namespace :assets do
    task :clean do
      on roles(:web) do
        within release_path do
          execute :rake, 'assets:clobber RAILS_ENV=production'
        end
      end
    end
  end
end

Docker Build Optimization #

FROM ruby:3.4-alpine

# Install dependencies for asset compilation
RUN apk add --no-cache nodejs npm

WORKDIR /app

# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install

# Copy application
COPY . .

# Precompile assets (much faster with Propshaft)
RUN RAILS_ENV=production SECRET_KEY_BASE=dummy bundle exec rails assets:precompile

EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]

CI/CD Pipeline Updates #

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4
          bundler-cache: true

      - name: Precompile assets
        run: |
          bundle exec rails assets:precompile
        env:
          RAILS_ENV: production
          SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}

      - name: Deploy
        run: |
          # Your deployment commands

Monitoring Post-Migration #

Set up monitoring for asset-related issues:

# config/initializers/asset_monitoring.rb
Rails.application.configure do
  config.middleware.use(Rack::Attack) if Rails.env.production?

  # Monitor 404s on asset requests
  ActiveSupport::Notifications.subscribe('process_action.action_controller') do |name, start, finish, id, payload|
    if payload[:path]&.start_with?('/assets/') && payload[:status] == 404
      Rails.logger.error "Asset not found: #{payload[:path]}"
      # Send to monitoring service (e.g., Sentry, New Relic)
    end
  end
end

Production Case Studies and Real-World Results #

Here’s what we’ve seen in actual migrations. If you’re also containerizing your Rails app, check our Rails 8 Docker deployment guide for how Propshaft interacts with Docker-based builds.

Case Study 1: E-Commerce Platform Migration #

Background: #

  • Application: Large e-commerce Rails application
  • Assets: 450+ JavaScript files, 200+ stylesheets
  • Previous setup: Sprockets with heavy CoffeeScript usage
  • Team size: 8 developers

Migration Timeline: #

Week 1-2: Assessment and Planning #

  • Audited 450+ asset files
  • Identified 87 CoffeeScript files requiring conversion
  • Documented 23 Sass files with complex mixins
  • Created migration checklist and rollback plan

Week 3-4: Preparation #

# Converted CoffeeScript to JavaScript
$ find app/assets/javascripts -name "*.coffee" | wc -l
87
$ decaffeinate app/assets/javascripts/**/*.coffee
# Manual review and cleanup of converted files

# Set up Dart Sass for preprocessing
$ bundle add dartsass-rails

Week 5-6: Migration Execution #

# Switched to Propshaft
gem 'propshaft'
# Removed gem 'sprockets-rails'

# config/application.rb
config.assets.pipeline = :propshaft

# Restructured assets
$ mv app/assets/javascripts app/javascript

Week 7: Testing and Deployment #

  • Comprehensive testing across 50+ pages
  • Staged rollout: 10% → 50% → 100% of traffic
  • Zero downtime deployment using blue-green strategy

Results: #

MetricBefore (Sprockets)After (Propshaft)Change
Asset precompile127.3s12.8s90% faster
Full deployment892s445s50% faster
CI pipeline1240s687s45% faster

The team also measured runtime improvements: first paint dropped by 0.4s, time to interactive improved by 0.7s, and their Lighthouse performance score jumped from 83 to 95. Cache hit ratio improved by 23% because individual file digests meant most assets survived deploys untouched.

On the developer experience side, hot reload got 3.2 seconds faster, the team deployed 2.3x more often, and production incidents related to the asset pipeline dropped by 67%.

What We Learned: #

The CoffeeScript conversion ate most of the migration time. Automated tooling handled the syntax, but the team spent days reviewing edge cases by hand. Import maps turned out to be a net simplifier because they eliminated the npm package conflicts the team had been fighting for years. HTTP/2 multiplexing handled 40+ concurrent asset requests without degradation, which surprised even the optimists on the team. And the monitoring setup they built during migration caught 12 missing-asset issues before any user saw them.

# Monitoring setup that caught 12 issues before production
# config/initializers/asset_monitoring.rb
Rails.application.configure do
  ActiveSupport::Notifications.subscribe('load.propshaft') do |name, start, finish, id, payload|
    if payload[:path].nil?
      Sentry.capture_message("Missing asset: #{payload[:logical_path]}")
    end
  end
end

Case Study 2: SaaS Application with Microservices #

Background: #

  • Application: Multi-tenant SaaS platform
  • Architecture: 5 Rails services sharing asset pipeline
  • Assets: 280+ files across services
  • Complexity: Shared component library

Migration Challenge: #

Coordinating asset pipeline changes across 5 microservices while maintaining shared component compatibility.

Solution Architecture: #

# Shared asset gem approach
# shared_assets/shared_assets.gemspec
Gem::Specification.new do |spec|
  spec.name          = "shared_assets"
  spec.version       = "1.0.0"
  spec.files         = Dir["app/assets/**/*"]
  spec.add_dependency "propshaft"
end

# Each microservice's Gemfile
gem 'shared_assets', path: '../shared_assets'

# config/application.rb (in each service)
config.assets.paths << SharedAssets.asset_path

Phased Rollout Strategy: #

The team migrated services in dependency order, starting with the simplest:

ServiceDependenciesAssetsMigration Week
analytics_service0451-2
auth_service1322-3
admin_service193
reporting_service2384
core_service31565-6

They started with analytics (zero dependencies, low risk) and saved core_service for last because it had the most shared assets and the highest dependency count.

Results: #

The team completed the migration across all 5 services in 6 weeks with zero downtime and zero rollbacks. Asset compile times dropped by 88%, and the shared asset cache hit rate reached 94%.

On the cost side, the faster builds saved roughly $4,800/year in CI pipeline costs, better caching cut CDN bandwidth by $2,100/year, and the team estimated $14,200/year in developer time savings from faster deploys. Those numbers add up when you multiply across 5 services.

Implementation Highlights: #

// Shared component with import map
// shared_assets/app/assets/javascripts/components/modal.js
export class Modal {
  constructor(element) {
    this.element = element;
    this.setupEventListeners();
  }

  setupEventListeners() {
    this.element.querySelector('.close').addEventListener('click', () => {
      this.close();
    });
  }

  open() {
    this.element.classList.add('active');
  }

  close() {
    this.element.classList.remove('active');
  }
}

// Each service's import map pins the shared component
// config/importmap.rb
pin "components/modal", to: "shared_assets/components/modal.js"

Case Study 3: Legacy Application Gradual Migration #

Background: #

  • Application: 10-year-old Rails monolith
  • Assets: 600+ files with heavy jQuery dependencies
  • Challenge: Cannot afford complete rewrite
  • Goal: Incremental modernization

Hybrid Approach Strategy: #

# Running Propshaft and Sprockets simultaneously during transition
# Gemfile
gem 'propshaft'
gem 'sprockets-rails'  # Keep temporarily for legacy assets

# config/application.rb
config.assets.pipeline = :propshaft

# config/environments/production.rb
# Serve legacy assets from separate path
config.assets.prefix = '/assets'

# Mount legacy Sprockets assets via Rack::Static
config.middleware.insert_before ActionDispatch::Static, Rack::Static,
  urls: ['/legacy-assets'], root: Rails.root.join('public')

Incremental Migration Plan: #

PhaseDurationScopeAssets MigratedApproach
12 monthsNew features only45Build new features with Propshaft/import maps
23 monthsHigh-traffic pages120Migrate pages covering 80% of traffic
34 monthsAdmin/internal tools200Modernize internal tooling with lower risk
43 monthsRemaining pages235Complete migration, remove Sprockets

The key insight was starting with new features. Every new page the team built used Propshaft from day one, so the legacy surface area stopped growing while the team chipped away at existing pages.

Feature Flag Implementation: #

# lib/asset_pipeline_feature_flag.rb
class AssetPipelineFeatureFlag
  def self.use_propshaft_for?(controller_name, action_name)
    # Gradual rollout based on traffic patterns
    migrated_routes = [
      {controller: "home", action: "index"},
      {controller: "products", action: "show"},
      {controller: "cart", action: "index"}
    ]

    migrated_routes.any? do |route|
      route[:controller] == controller_name &&
      route[:action] == action_name
    end
  end
end

# app/views/layouts/application.html.erb
<% if  AssetPipelineFeatureFlag.use_propshaft_for?(controller_name, action_name) %>
  <%= javascript_importmap_tags %>
<% else  %>
  <%= javascript_include_tag "application", "data-turbo-track": "reload" %>
<% end  %>

Results After 12-Month Migration: #

The team migrated all 600 assets. Build time dropped from 187.5 seconds to 14.2 seconds, a 92% improvement.

Page loads improved across the board: the homepage loaded 1.2 seconds faster, product pages gained 0.8 seconds, and checkout improved by 0.6 seconds. Cache hit rates jumped from 67% to 91% because individual file digests meant most assets survived code changes. Average cache size per user dropped from 8.7MB to 2.3MB, cutting bandwidth by 73%.

What Made This Work: #

The founders gave the team a 12-month runway for incremental migration instead of demanding a big-bang cutover. Two developers worked on it full-time, which sounds expensive until you compare it to the cost of a botched migration on a 10-year-old monolith. The team built monitoring before they migrated a single asset, so they could track performance at every phase. And they ran A/B tests comparing Propshaft and Sprockets in production, which gave them hard data to justify continuing the migration when stakeholders got nervous.

After 12 months, build times dropped from over 3 minutes to under 15 seconds, and the asset pipeline stopped being a topic at standup.

If you’re planning a large-scale migration and want a second pair of eyes, our Rails development team has done this migration dozens of times.

Troubleshooting Common Migration Issues #

Even with careful planning, Propshaft migrations can encounter challenges. This section covers the most common issues and their solutions based on real-world migration experiences.

Issue 1: Missing Asset Errors in Production #

Symptom: #

ActionView::Template::Error: The asset "components/modal.js" is not present in the asset pipeline

Cause: Asset path configuration or importmap misconfiguration

Solution: #

# 1. Verify asset exists in correct location
$ ls app/javascript/components/modal.js

# 2. Check import map configuration
# config/importmap.rb
pin "components/modal", to: "components/modal.js"

# 3. Precompile and verify
$ RAILS_ENV=production bin/rails assets:precompile
$ ls public/assets/components/modal-*.js

# 4. Check asset path in production
# config/environments/production.rb
config.assets.prefix = '/assets'  # Should match public/assets location

Prevention Strategy: #

# lib/tasks/verify_assets.rake
namespace :assets do
  desc "Verify all assets are accessible"
  task verify: :environment do
    missing_assets = []

    # Check manifest.json exists
    manifest_path = Rails.root.join("public/assets/.manifest.json")
    unless File.exist?(manifest_path)
      puts "❌ Missing manifest.json - run rails assets:precompile first"
      exit 1
    end

    # Parse importmap.rb
    importmap_file = Rails.root.join("config/importmap.rb")
    importmap_content = File.read(importmap_file)

    # Extract pinned assets
    pins = importmap_content.scan(/pin\s+"([^"]+)"/)

    # Load manifest for digest lookup
    manifest = JSON.parse(File.read(manifest_path))

    pins.each do |pin_name|
      logical = pin_name[0]

      # Check manifest for digested version
      digested = manifest[logical] || manifest["#{logical}.js"]

      if digested
        # Verify digested file exists (handle both filename and full path)
        digested_basename = File.basename(digested)
        full_path = Rails.root.join("public/assets", digested_basename)

        # Also check with glob for digest variations
        glob_pattern = Rails.root.join("public/assets/#{logical.sub('.js', '')}-*.js")
        glob_matches = Dir.glob(glob_pattern)

        unless File.exist?(full_path) || glob_matches.any?
          missing_assets << logical
        end
      else
        missing_assets << logical
      end
    end

    if missing_assets.any?
      puts "❌ Missing assets:"
      missing_assets.each { |asset| puts "  - #{asset}" }
      exit 1
    else
      puts "✅ All assets verified"
    end
  end
end

# Run in CI pipeline before deployment
$ bin/rails assets:verify

Issue 2: Stylesheet Import Order Problems #

Symptom: #

CSS specificity issues: styles applying in wrong order
Components not styling correctly

Cause: HTTP/2 multiplexing doesn’t guarantee stylesheet load order

Solution: #

# BAD: Multiple stylesheet_link_tag calls (unpredictable order)
<%= stylesheet_link_tag "application" %>
<%= stylesheet_link_tag "components" %>
<%= stylesheet_link_tag "utilities" %>

# GOOD: Single consolidated stylesheet or explicit ordering
# Option 1: Consolidate stylesheets
# app/assets/stylesheets/application.css
/* Load in specific order */
@import "normalize.css";
@import "variables.css";
@import "base.css";
@import "components.css";
@import "utilities.css";

# Option 2: Use data-turbo-track with explicit order
<%= stylesheet_link_tag "application", "data-turbo-track": "reload", media: "all" %>

For Sass/SCSS Projects: #

# Use Dart Sass for preprocessing
# Gemfile
gem 'dartsass-rails'

# config/initializers/dartsass.rb
Rails.application.config.dartsass.builds = {
  "application.scss" => "application.css"
}

# app/assets/stylesheets/application.scss
// Explicit import order
@import "base/variables";
@import "base/mixins";
@import "base/reset";
@import "components/buttons";
@import "components/forms";
@import "layouts/header";
@import "layouts/footer";

Issue 3: Third-Party Library Integration #

Symptom: #

Uncaught ReferenceError: $ is not defined
jQuery plugins not working
Bootstrap JavaScript not initializing

Cause: Third-party libraries not properly configured in import maps

Solution: #

# config/importmap.rb

# Option 1: Pin from CDN (recommended for common libraries)
pin "jquery", to: "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"
pin "bootstrap", to: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"

# Option 2: Download to vendor/javascript and pin locally
$ bin/importmap pin jquery --download
$ bin/importmap pin bootstrap --download

# Option 3: For jQuery plugins requiring global $
# app/javascript/application.js
import $ from "jquery"
window.$ = window.jQuery = $  // Make jQuery global

import "jquery-ui"            // Now jQuery plugins work
import "select2"

For Bootstrap Integration: #

# Pin Bootstrap JavaScript
# config/importmap.rb
pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"
pin "bootstrap", to: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"

# app/javascript/application.js
import "@popperjs/core"
import "bootstrap"

// Initialize Bootstrap components
document.addEventListener("turbo:load", () => {
  const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
  tooltipTriggerList.map(el => new bootstrap.Tooltip(el))
})

Issue 4: Image Asset Path Resolution #

Symptom: #

<%= image_tag "logo.png" %>
<!-- Renders: <img src="/assets/logo.png"> -->
<!-- But actual path is: /assets/logo-abc123.png -->
<!-- Result: 404 error -->

Cause: Asset helper not generating digested filenames

Solution: #

# Verify Propshaft is active
# config/application.rb
config.assets.pipeline = :propshaft

# Ensure image_tag uses asset pipeline
# app/views/layouts/application.html.erb
<%= image_tag "logo.png" %>
<!-- Should render: <img src="/assets/logo-abc123.png"> -->

# For images in CSS
/* app/assets/stylesheets/application.css */
.logo {
  background-image: url('/assets/logo.png');  /* ❌ Wrong */
  background-image: asset-url('logo.png');    /* ✅ Correct with sassc-rails */
}

# Or use inline styles with ERB
<div style="background-image: url(<%= asset_path('logo.png') %>)"></div>

Asset Path Debugging: #

# rails console
# Note: Propshaft doesn't expose load_path for scanning like Sprockets
# Use asset_path helpers to verify asset resolution instead

> helper.asset_path("logo.png")
# Should return digested path: "/assets/logo-abc123.png"

> helper.image_path("logo.png")
# Alternative helper for image assets

# Verify compiled assets exist in public/assets/
> Dir.glob(Rails.root.join("public/assets/logo-*.png"))
# Should return array of digested filenames

Issue 5: Slow Build Times Despite Propshaft #

Symptom: #

$ time RAILS_ENV=production bin/rails assets:precompile
real    2m14.382s  # Still slow!

Cause: External preprocessors (Sass, TypeScript) running slowly

Diagnosis and Solution: #

# Identify bottlenecks
$ RAILS_ENV=production bin/rails assets:precompile --trace

# Look for slow tasks:
# ** Invoke dartsass:build (9.234s)
# ** Invoke javascript:build (18.542s)

# Optimize Dart Sass compilation
# package.json
{
  "scripts": {
    "build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --style=compressed --no-source-map"
  }
}

# Parallel asset processing
# lib/tasks/assets.rake
namespace :assets do
  task precompile: :environment do
    # Run CSS and JS builds in parallel
    threads = []

    threads << Thread.new do
      system("npm run build:css")
    end

    threads << Thread.new do
      system("npm run build:js")
    end

    threads.each(&:join)

    # Then run Propshaft compilation (integrated with assets:precompile)
    Rake::Task["assets:precompile"].invoke
  end
end

Optimize Import Map Resolution: #

# config/importmap.rb
# Cache remote imports locally for faster builds
$ bin/importmap pin jquery --download
$ bin/importmap pin bootstrap --download

# Now imports resolve locally instead of hitting CDN during build

Issue 6: Development Mode Performance #

Symptom: #

Page reload takes 5-10 seconds in development
Assets not hot-reloading

Solution: #

# config/environments/development.rb
Rails.application.configure do
  # Enable asset debugging
  config.assets.debug = true

  # Serve assets through Rails
  config.public_file_server.enabled = true

  # Disable asset digesting in development
  config.assets.digest = false

  # Enable caching in development for faster reloads
  config.action_controller.perform_caching = true
  config.cache_store = :memory_store
end

# For CSS hot reload
# Gemfile
gem 'listen'  # File change detection

# config/environments/development.rb
config.file_watcher = ActiveSupport::EventedFileUpdateChecker

Import Map Development Mode: #

# app/views/layouts/application.html.erb
<!-- Disable preloading in development for faster reloads -->
<% if  Rails.env.development? %>
  <%= javascript_importmap_tags "application", async: false, defer: false %>
<% else  %>
  <%= javascript_importmap_tags %>
<% end  %>

We’ve seen these same issues on most migrations we’ve done. When you hit something not covered here, systematic debugging using Rails console asset inspection and build process tracing usually reveals the root cause.

FAQ: Propshaft Migration Questions #

Q: Can I migrate to Propshaft without Rails 8? #

A: Yes. Propshaft works with Rails 7.0+. You can install it on Rails 7.1 or 7.2:

# Gemfile
gem 'propshaft'

# config/application.rb
config.assets.pipeline = :propshaft

However, Rails 8 includes Propshaft as the default, providing better integration and official support.

Q: What happens to my existing Sprockets assets after migration? #

A: Your compiled Sprockets assets in public/assets/ remain until you delete them. During migration:

# Clean old Sprockets assets
$ bin/rails assets:clobber

# Compile new Propshaft assets
$ RAILS_ENV=production bin/rails assets:precompile

# Verify old assets are gone
$ ls public/assets/  # Should only show Propshaft digested files

Q: How do I handle Sass/SCSS with Propshaft? #

A: Use dartsass-rails or sassc-rails for preprocessing:

# Gemfile
gem 'dartsass-rails'

# This compiles Sass before Propshaft processes assets
# app/assets/stylesheets/application.scss compiled to
# app/assets/builds/application.css (which Propshaft serves)

Q: Can I use Propshaft with Webpacker or esbuild? #

A: Yes, Propshaft handles compiled output from any build tool:

# Use esbuild for JavaScript bundling
# Gemfile
gem 'jsbundling-rails'

# package.json
{
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --outdir=app/assets/builds"
  }
}

# Propshaft serves the bundled output from app/assets/builds/

Q: Does Propshaft work with Turbo/Stimulus? #

A: Yes, perfectly. Import maps are the recommended approach:

# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

Q: What’s the performance impact in production? #

A: Based on our case studies:

  • Build time: 85-95% faster (Propshaft vs Sprockets)
  • Page load: 15-35% faster (HTTP/2 multiplexing + better caching)
  • Cache efficiency: 60-80% improvement (granular file invalidation)
  • Memory usage: 75-85% lower during compilation

Q: How do I handle CDN configuration? #

A: Propshaft works well with CDNs:

# config/environments/production.rb
config.asset_host = 'https://cdn.example.com'

# Propshaft generates correct asset URLs automatically
# <img src="https://cdn.example.com/assets/logo-abc123.png">

Q: Can I roll back to Sprockets if needed? #

A: Yes, but plan for it before migration:

# Keep Sprockets temporarily during migration
# Gemfile
gem 'propshaft'
gem 'sprockets-rails'  # Keep for rollback capability

# Switch back if needed
# config/application.rb
config.assets.pipeline = :sprockets  # Rollback

After successful migration, remove Sprockets:

# Gemfile (after confirming migration success)
gem 'propshaft'
# gem 'sprockets-rails'  # Removed

Q: What about Asset Sync (for S3/CloudFront)? #

A: Use asset_sync gem with Propshaft:

# Gemfile
gem 'asset_sync'

# config/initializers/asset_sync.rb
AssetSync.configure do |config|
  config.fog_provider = 'AWS'
  config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID']
  config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
  config.fog_directory = ENV['FOG_DIRECTORY']
  config.fog_region = ENV['FOG_REGION']
end

# Automatically syncs compiled Propshaft assets to S3
$ RAILS_ENV=production bin/rails assets:precompile

Propshaft is simpler, faster, and the right default for new Rails 8 apps. For existing apps on Sprockets, the migration is worth it when asset compilation is a real bottleneck—not before.

Assess your asset stack first. Run bin/rails assets:precompile and time it. If it hurts, migrate. If it doesn’t, ship features instead and revisit later. When you do migrate, do it in phases: swap the gem, fix broken paths, test in staging, then deploy. The Kamal 2 deployment guide covers how to automate the deploy side of this transition.

If you’re modernizing your full Rails 8 stack, our guides on Solid Cache migration from Redis and upgrading to Argon2 password hashing cover the other pieces.