← You Don't Need Redis

Chapter 9: Ruby & Rails: The Scenic Route

The Waiter of Gold Lapel · Updated Mar 30, 2026 Published Mar 11, 2026 · 14 min

The Ruby on Rails ecosystem has, if I may say so, the most polished materialized view integration of any framework examined in this book.

This is largely thanks to the Scenic gem, originally by thoughtbot and now maintained by the scenic-views community — approximately 3,600 stars on GitHub, used in production by Mastodon, Code.org, and Lobsters — which treats materialized views as versioned, migration-managed database objects. Where other frameworks require workarounds, raw SQL migrations, and manual model configuration, Scenic provides generators, version tracking, dependency management, and a zero-downtime update strategy that I consider genuinely elegant. It treats materialized views the way Rails treats everything else: as first-class citizens of the framework, managed through conventions that minimize ceremony and maximize reliability.

Rails 8's removal of Redis from its default application stack — replacing it with SolidQueue for background jobs, SolidCache for caching, and SolidCable for WebSocket connections, all PostgreSQL-backed — makes this chapter's thesis particularly resonant. The framework that popularized Redis-backed background processing has officially decided that Redis is optional. Materialized views complete the picture.

Without Scenic: Raw Rails

Before we examine Scenic, allow me to show the raw approach — materialized views in Rails without any additional gem. This establishes the baseline that Scenic improves upon and serves developers who prefer minimal dependencies.

The migration

create_dashboard_metrics.rb
class CreateDashboardMetrics < ActiveRecord::Migration[7.2]
  def up
    execute <<-SQL
      CREATE MATERIALIZED VIEW mv_dashboard AS
      SELECT
          date_trunc('day', created_at)::date AS day,
          COUNT(*)::integer AS events,
          COUNT(DISTINCT user_id)::integer AS unique_users,
          SUM(CASE WHEN event_type = 'purchase'
              THEN amount ELSE 0 END)::numeric(13,2) AS revenue
      FROM events
      WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
      GROUP BY 1
      WITH DATA;

      CREATE UNIQUE INDEX idx_mv_dashboard_day ON mv_dashboard (day);
    SQL
  end

  def down
    execute "DROP MATERIALIZED VIEW IF EXISTS mv_dashboard CASCADE"
  end
end

The model

dashboard_metric.rb
class DashboardMetric < ApplicationRecord
  self.table_name = 'mv_dashboard'
  self.primary_key = 'day'

  def readonly?
    true
  end

  def self.refresh(concurrently: true)
    keyword = concurrently ? "CONCURRENTLY" : ""
    connection.execute(
      "REFRESH MATERIALIZED VIEW #{keyword} #{table_name}"
    )
  end
end

self.table_name points ActiveRecord at the materialized view. readonly? prevents accidental writes — ActiveRecord will raise ActiveRecord::ReadOnlyRecord if any code attempts to save, update, or destroy a record. The refresh class method encapsulates the REFRESH SQL with a concurrently option.

Standard ActiveRecord querying works immediately:

console
DashboardMetric.order(day: :desc).limit(30)
DashboardMetric.where(day: 30.days.ago..Date.today)
DashboardMetric.sum(:revenue)

This approach works. For one or two independent materialized views with infrequent schema changes, it is perfectly adequate. But when the query needs to change, the migration becomes a manual DROP-and-recreate operation with index recreation. When multiple materialized views depend on each other, the ordering becomes your responsibility. When you need zero-downtime updates, you're on your own.

Scenic addresses all of this.

The Scenic Gem: Materialized Views Done Right

Gemfile
# Gemfile
gem 'scenic'
terminal
rails generate scenic:view dashboard_metrics --materialized

This single command creates two files:

db/views/dashboard_metrics_v01.sql — the SQL definition, in a standalone file with full syntax highlighting:

dashboard_metrics_v01.sql
SELECT
    date_trunc('day', created_at)::date AS day,
    COUNT(*)::integer AS events,
    COUNT(DISTINCT user_id)::integer AS unique_users,
    SUM(CASE WHEN event_type = 'purchase'
        THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1

Pure SQL. No Ruby string interpolation. No heredoc escaping. You can copy this file directly into psql and test it. Your editor provides full SQL syntax highlighting. Your code reviewer can read it without mentally parsing Ruby.

db/migrate/XXXX_create_dashboard_metrics.rb — the migration:

create_dashboard_metrics.rb
class CreateDashboardMetrics < ActiveRecord::Migration[7.2]
  def change
    create_view :dashboard_metrics, materialized: true
    add_index :dashboard_metrics, :day, unique: true
    add_index :dashboard_metrics, :day, order: { day: :desc },
              name: 'idx_dashboard_metrics_day_desc'
  end
end

create_view with materialized: true — Scenic handles the CREATE MATERIALIZED VIEW statement, reading the SQL from the versioned file. Standard Rails add_index calls add indexes exactly as they would for a regular table. The UNIQUE index enables REFRESH CONCURRENTLY.

The model

terminal
rails generate scenic:model dashboard_metric --materialized

Generates:

dashboard_metric.rb
class DashboardMetric < ApplicationRecord
  def self.refresh
    Scenic.database.refresh_materialized_view(
      table_name,
      concurrently: false,
      cascade: false
    )
  end
end

The refresh method is generated automatically. In production, you'll want concurrently: true (once the UNIQUE index exists) and potentially cascade: true if this view feeds other materialized views.

Querying is standard ActiveRecord — the model doesn't know or care that it's backed by a materialized view rather than a table:

dashboard_metric.rb
# In a controller or API endpoint
recent = DashboardMetric.order(day: :desc).limit(30)

# Scopes work naturally
class DashboardMetric < ApplicationRecord
  scope :last_30_days, -> { where(day: 30.days.ago..Date.today) }
  scope :with_revenue, -> { where('revenue > 0') }
end

# Associations work if the MV includes foreign keys
class DashboardMetric < ApplicationRecord
  belongs_to :category, optional: true
end

# Works with Jbuilder, ActiveModel Serializers, or any API layer

Cascade refresh for dependent views

When materialized views depend on each other — a daily metrics view feeding a weekly rollup — Scenic's cascade option handles the ordering:

daily_metric.rb
class DailyMetric < ApplicationRecord
  def self.refresh
    Scenic.database.refresh_materialized_view(
      table_name,
      concurrently: true,
      cascade: true  # refresh dependent views after this one
    )
  end
end

With cascade: true, refreshing the daily view automatically refreshes any views that depend on it. The dependency graph is resolved by Scenic — you declare the relationships, and the refresh cascades in the correct order.

Updating Views: Versioning and the Side-by-Side Strategy

When the defining query needs to change — a new column, a modified aggregation, an updated WHERE clause — generate a new version:

terminal
rails generate scenic:view dashboard_metrics --materialized

Scenic creates db/views/dashboard_metrics_v02.sql (your new query definition) and a migration:

update_dashboard_metrics_to_version_2.rb
class UpdateDashboardMetricsToVersion2 < ActiveRecord::Migration[7.2]
  def change
    update_view :dashboard_metrics,
                version: 2,
                revert_to_version: 1,
                materialized: true
  end
end

The revert_to_version parameter ensures that rails db:rollback restores the previous version cleanly. Your version history lives in db/views/ — auditable, diffable, reviewable in pull requests. You can see exactly what changed between v1 and v2 with a standard diff.

Scenic automatically reapplies indexes when a view is updated. Other approaches — including the raw Rails migration pattern — silently drop indexes when the view is recreated. The developer discovers this in production, usually when query performance degrades and the EXPLAIN output reveals sequential scans where index scans should be. Scenic prevents this by tracking indexes and reapplying them after the view is recreated.

The side_by_side strategy

Standard materialized view updates require dropping the old view and creating the new one. During the refresh that populates the new view, it is unavailable for reads — potentially for minutes if the defining query is complex. For production applications serving traffic, this creates an unacceptable downtime window.

Scenic's side_by_side strategy solves this:

update_dashboard_metrics_to_version_2.rb
class UpdateDashboardMetricsToVersion2 < ActiveRecord::Migration[7.2]
  def change
    update_view :dashboard_metrics,
                version: 2,
                revert_to_version: 1,
                materialized: { side_by_side: true }
  end
end

The process: Scenic creates the new version of the view under a temporary name and populates it with fresh data. Reads continue against the old version throughout the population phase. Once the new view is ready, Scenic drops the old version and renames the new one to the production name. The window of unavailability — the instant between the DROP and the RENAME — is negligible compared to the minutes a full refresh from scratch might take.

This is, to my knowledge, the most elegant near-zero-downtime materialized view update mechanism in any web framework. It is the feature that justifies adding Scenic to your Gemfile even for a single materialized view.

Production Scheduling: Three Paths, No Redis Required

SolidQueue: the Rails 8 default

SolidQueue ships with Rails 8 as the default background job backend. It stores jobs in your PostgreSQL database. No Redis. No additional infrastructure. 37signals processes 20 million background jobs per day with it.

For recurring materialized view refresh, SolidQueue uses a YAML configuration:

config/recurring.yml
# config/recurring.yml
production:
  refresh_dashboard:
    class: RefreshDashboardJob
    schedule: "*/15 * * * *"
    queue: maintenance
refresh_dashboard_job.rb
class RefreshDashboardJob < ApplicationJob
  queue_as :maintenance

  def perform
    DashboardMetric.refresh
  rescue => e
    Rails.logger.error("Dashboard MV refresh failed: #{e.message}")
    raise # re-raise for SolidQueue's retry mechanism
  end
end

Standard cron syntax. Standard Active Job class. The refresh runs every 15 minutes, managed by SolidQueue, stored in PostgreSQL. If you're using Rails 8, this is the default path — no additional gems, no configuration beyond the YAML file and the job class.

Good Job: the PostgreSQL-native veteran

Good Job, by Ben Sheldon, predates SolidQueue and offers a more mature feature set for recurring jobs. It is PostgreSQL-backed with built-in cron scheduling and a web dashboard for monitoring:

config/initializers/good_job.rb
# Gemfile
gem 'good_job'

# config/initializers/good_job.rb
Rails.application.configure do
  config.good_job.enable_cron = true
  config.good_job.cron = {
    refresh_dashboard: {
      cron: "*/15 * * * *",
      class: "RefreshDashboardJob",
      description: "Refresh dashboard materialized view"
    }
  }
end

Good Job uses PostgreSQL advisory locks for concurrency control. If multiple application instances are running and each attempts the same cron job, only one acquires the lock and executes — the others skip silently. This is the advisory lock pattern from Chapter 5, implemented at the gem level. You get multi-instance safety without writing any coordination code.

Sidekiq: the industry standard

refresh_dashboard_worker.rb
class RefreshDashboardWorker
  include Sidekiq::Worker
  sidekiq_options retry: 3, queue: :maintenance

  def perform
    DashboardMetric.refresh
  end
end

Schedule with sidekiq-cron:

initializer
Sidekiq::Cron::Job.create(
  name: 'refresh-dashboard',
  cron: '*/15 * * * *',
  class: 'RefreshDashboardWorker'
)

Sidekiq is excellent, battle-tested, and deployed in thousands of production applications. It also requires Redis — which, given the thesis of this book and the direction Rails 8 has taken, is worth noting. SolidQueue and Good Job achieve the same result without the additional infrastructure. The choice is yours; the Waiter has made his preference clear.

pg_cron: the simplest option

If your materialized view refresh doesn't require application logic — no error routing to exception trackers, no conditional checks, no integration with deployment pipelines — pg_cron eliminates the Ruby code entirely:

psql
SELECT cron.schedule('refresh-dashboard', '*/15 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard');

Three lines of SQL. No gem. No worker process. No application deployment required. The refresh runs inside PostgreSQL regardless of whether your Rails application is running, deploying, or restarting.

The Redis-Free Rails Stack

Rails 8 with materialized views provides a complete application architecture that requires exactly one external service: PostgreSQL.

ConcernSolutionBacked by
Background jobsSolidQueuePostgreSQL. Ships with Rails 8. 20M jobs/day at 37signals.
CachingSolidCachePostgreSQL. Replaces the Redis-backed cache store.
WebSocketsSolidCablePostgreSQL via LISTEN/NOTIFY. Replaces ActionCable's Redis adapter.
Complex query pre-computationMaterialized viewsPostgreSQL-native. Managed by Scenic. Refreshed by SolidQueue.
Pub/sub for application eventsLISTEN/NOTIFYPostgreSQL-native. No additional infrastructure.
Session storagePostgreSQLactiverecord-session_store gem or an UNLOGGED table per Chapter 6.
SearchFull-text searchGIN indexes on materialized views. PostgreSQL-native. Chapter 4's Pattern 3.

One database. One household. Properly attended to.

Rails 8's removal of Redis from the default stack is not merely a technical decision. It is a statement about where the framework's architects believe the industry is heading. The techniques in this book are not experimental. They are not contrarian opinions dressed up as engineering advice. They are the direction that the most opinionated framework in web development has chosen as its default.

In the next chapter, we attend to Java and Spring Boot — where the ceremony is greater but the materialized view, I assure you, is no less effective.