Propshaft vs Sprockets: Complete Rails 8 Asset Pipeline Migration Guide

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
@extendacross dozens of files. You’ll need to addsassc-railsordartsass-railsas 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:
- CoffeeScript usage: Requires conversion to modern JavaScript
- Sass/SCSS with complex features: May need preprocessing solution
- Asset gems: Verify Propshaft compatibility
- Custom Sprockets processors: Need alternative implementation
- Heavy use of
requiredirectives: 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: #
| Metric | Before (Sprockets) | After (Propshaft) | Change |
|---|---|---|---|
| Asset precompile | 127.3s | 12.8s | 90% faster |
| Full deployment | 892s | 445s | 50% faster |
| CI pipeline | 1240s | 687s | 45% 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:
| Service | Dependencies | Assets | Migration Week |
|---|---|---|---|
| analytics_service | 0 | 45 | 1-2 |
| auth_service | 1 | 32 | 2-3 |
| admin_service | 1 | 9 | 3 |
| reporting_service | 2 | 38 | 4 |
| core_service | 3 | 156 | 5-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: #
| Phase | Duration | Scope | Assets Migrated | Approach |
|---|---|---|---|---|
| 1 | 2 months | New features only | 45 | Build new features with Propshaft/import maps |
| 2 | 3 months | High-traffic pages | 120 | Migrate pages covering 80% of traffic |
| 3 | 4 months | Admin/internal tools | 200 | Modernize internal tooling with lower risk |
| 4 | 3 months | Remaining pages | 235 | Complete 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.