Upkeep Rails
Upkeep automatically syncs record changes to browsers showing affected data. When you save a record, it figures out which rendered pages depend on it and sends Turbo Streams to the right subscribers.
Instead of naming stream targets in your controller:
class CardsController < ApplicationController
def create
@card = Card.create!(card_params)
respond_to { |f| f.turbo_stream }
end
end
<%= turbo_stream.append "cards", partial: "card", locals: { card: @card } %>
You just save the record:
class CardsController < ApplicationController
def create
Card.create!(card_params)
head :no_content
end
end
Upkeep observed which records the initial GET read, so it knows the page depends on the card table. When the create commits, it broadcasts to the right browsers automatically.
Install
Add the gem to your Gemfile:
gem "upkeep-rails"
Run the installer:
bin/rails generate upkeep:install
bin/rails db:migrate
This creates the subscription tables, writes an initializer, mounts ActionCable if needed, and imports the browser client.
Requirements: Ruby 3.2+, Rails 7.1+, Turbo 2.0+.
Configure runtime
The installer creates config/initializers/upkeep.rb. Most projects work with the defaults, but you can customize:
Upkeep::Rails.configure do |config|
config.subscription_store = :active_record # :memory for tests
config.deliver_inline = false # true for tests/console
config.refused_boundary_behavior = :warn # :raise in strict mode
end
Subscription store: Use :memory for test suites. Use :active_record for production—it stores subscriptions durably across restarts.
Delivery mode: Upkeep broadcasts from a background dispatcher in the same process that performed the write. For tests and console sessions where you want synchronous behavior, set deliver_inline = true.
Subscription lifecycle: Subscriptions clean themselves up. A clean disconnect deletes the subscription immediately; abandoned subscriptions (crashed browsers, dropped connections) are trimmed opportunistically in small batches once they go untouched for config.subscription_ttl (default 24 hours); and connected pages send a liveness heartbeat every 20 minutes, so a long-lived open tab is never pruned. Adjust subscription_ttl to control how long abandoned subscriptions may linger.
Multi-process deployments: If you run multiple Puma workers, you need a cross-process cable adapter so broadcasts reach all processes. We recommend solid_cable.
Configure identity
If your pages depend on the current user, account, or any viewer-specific value, tell Upkeep how to match that between render time and subscription time.
First, declare the identity in the initializer:
Upkeep::Rails.configure do |config|
config.identify :viewer, current: ["Current", :user] do
subscribe { |connection| connection.current_user }
end
end
Then make sure your cable connection exposes it:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = User.find_by(id: request.session[:user_id])
end
end
end
Pick the declaration that matches how your render path reads the identity:
| Render path | Configuration | Subscribe returns |
|---|---|---|
Current.user |
current: ["Current", :user] |
connection.current_user |
| Devise/Warden | warden: :user |
connection.current_user |
session[:user_id] |
session: :user_id |
connection.session[:user_id] |
cookies[:account_id] |
cookie: :account_id |
connection.cookies[:account_id] |
For logged-out pages where nil is a valid identity, declare how to recognize "absent":
config.identify :viewer, session: :user_id do
absent_if { |value| value.nil? || value == false }
subscribe { |connection| connection.session[:user_id] }
end
If your render path reads an undeclared identity value (like a CurrentAttributes field), Upkeep will refuse to register that page for live updates rather than guess who should receive the broadcast.
When Upkeep can't track your data
Upkeep needs to understand your queries to know which records affect which renders. It can't work with raw SQL strings, opaque joins, or objects that aren't tied back to the database schema.
These queries will cause Upkeep to refuse the page:
# Raw SQL predicates
Story.where("score >= 0")
Story.order("created_at DESC")
# Opaque joins
User.joins("INNER JOIN posts ON ...")
# From sources that aren't table names
Story.from("(SELECT * FROM stories WHERE ...)")
Rewrite them using Rails or Arel:
Story.where(Story.arel_table[:score].gteq(0))
Story.order(created_at: :desc)
By default, Upkeep raises an error in development and test, and logs a warning in production (but still renders the page). You can change this behavior:
Upkeep::Rails.configure do |config|
config.refused_boundary_behavior = :warn # or :raise
end
For queries that genuinely can't be made structural—like full-text search backed by raw tsvector—opt the request out entirely instead:
class StoriesController < ApplicationController
def index
@stories = params[:query].present? ? Story.search(params[:query]) : Story.all
end
private
def upkeep_reactive_request?
return false if params[:query].present?
super
end
end
The request still renders normally. Upkeep just doesn't register subscriptions or analyze the boundary. The unfiltered view (without a query param) stays reactive.
General rule: if Rails or Arel can describe the table, columns, predicates, and sort order, Upkeep can track it. If the query logic lives inside a SQL string, Upkeep will refuse the page.
How Upkeep compares to Turbo Streams
With vanilla Turbo Streams, the write action names which parts of the UI to refresh. You maintain the list of targets as your UI evolves:
class CardsController < ApplicationController
def create
@board = Board.find(params[:board_id])
@card = @board.cards.create!(card_params)
respond_to do |format|
format.turbo_stream
end
end
end
<%= turbo_stream.append "cards", partial: "card", locals: { card: @card } %>
<%= turbo_stream.update "board_open_count", @board.open_count %>
This couples the write path to the current UI. When you add a sidebar showing the same board, or a filter that depends on card status, you revisit the controller and stream template.
With Upkeep, the write path just saves the record. The subscription comes from the read:
class CardsController < ApplicationController
def create
Board.find(params[:board_id]).cards.create!(card_params)
head :no_content
end
end
Upkeep observed which records your initial GET read, so it knows which renders care about this card. It broadcasts to those subscribers automatically. You add a sidebar or filter without touching the write path.
The tradeoff: Turbo Streams let you control exactly what gets broadcast and how it's rendered (you might patch a counter without rerendering the list). Upkeep derives updates from renders it observed, so it's narrower but also more opinionated about safety.
| Concern | Turbo Streams | Upkeep |
|---|---|---|
| Write path | Declares targets and templates | Just commits the record |
| Boundary discovery | You maintain it | Upkeep infers from renders |
| Unsafe broadcasts | You decide how broad | Upkeep refuses unsafe patterns |
| Render changes | Update the stream template | Happens automatically |
| Full control | Yes | No, but less coupling |
See How Upkeep Works for details on the runtime model.