Rails 8.1: Resilient Jobs, Better Logs, and Local CI
•8 min read
Rails 8.1 is here, and after digging into the new features, it looks like a solid upgrade 🎯. This release focuses on developer experience and production reliability—job continuations, structured logging, and local CI are the standouts.
Let me walk you through what's new, what's worth your time, and what to watch out for.
1. Active Job Continuations: Finally, Resilient Background Jobs
The Problem
We've all been there: you deploy, and suddenly your long-running import job fails halfway through. Sidekiq restarts, the job starts from scratch, and your users wait another 20 minutes. Writing custom checkpointing logic for these situations is tedious and error-prone.
The Solution
Rails 8.1 introduces Active Job Continuations, which allow jobs to be broken into discrete steps that can resume after a restart. Think of it like savepoints in a video game—your job can pick up right where it left off.
The step method comes in three flavors:
class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable
  def perform(import_id)
    @import = Import.find(import_id)
    # 1. Block format - inline logic
    step :initialize do
      @import.update!(status: "processing")
      @import.download_from_s3
    end
    # 2. Block with cursor - for iterating through large datasets
    step :process do |step|
      @import.records.find_each(start: step.cursor) do |record|
        record.process!
        # Save progress after each record
        step.advance! from: record.id
      end
    end
    # 3. Method reference - cleanest for extracted logic
    step :finalize
  end
  private
  def finalize
    @import.update!(
      status: "completed",
      processed_at: Time.current
    )
    ImportMailer.completion_email(@import).deliver_later
  end
end
The magic is in that second format with step.cursor and step.advance!. When your job processes 5,000 out of 10,000 records and gets interrupted, it resumes at record 5,001 instead of starting over. The cursor is automatically saved and restored.
💡 Pro Tip: Use the cursor format for any step that iterates through large datasets. This would be perfect for CSV import jobs or any batch processing that deals with large collections.
🚨 Gotcha: Steps should still be idempotent (safe to run multiple times) when possible. While the cursor helps, network issues or database locks can cause partial processing within a step. Design your steps to handle being run twice safely.
2. Structured Event Reporting: Logs That Actually Help Debug
Why It Matters
If you've ever tried to parse Rails logs in production, you know the pain. Plain text logs are great for development, but searching, filtering, and monitoring structured data becomes difficult when everything is a string.
The New Way
Rails 8.1 introduces a unified Event Reporter with Rails.event.notify for structured, machine-readable logging. Instead of interpolating strings, you emit events with structured data:
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      # Old way: Rails.logger.info "User #{@user.id} signed up"
      # New way: structured and searchable
      Rails.event.notify("user.signup",
        user_id: @user.id,
        email: @user.email,
        plan: @user.plan
      )
      # Emits event with:
      # - name: "user.signup"
      # - payload: { user_id: 123, email: "...", plan: "..." }
      # - timestamp (nanoseconds)
      # - source_location (file, line, method)
      # - tags and context (if set)
      redirect_to dashboard_path
    else
      render :new, status: :unprocessable_entity
    end
  end
end
You can also add tags to categorize events:
Rails.event.tagged("graphql") do
  # All events inside this block will include tags: { graphql: true }
  Rails.event.notify("user.signup", user_id: 123, email: "user@example.com")
end
And set context that persists across events (perfect for request IDs and tenant IDs):
class ApplicationController < ActionController::Base
  before_action :set_event_context
  private
  def set_event_context
    Rails.event.set_context(
      request_id: request.request_id,
      shop_id: current_shop&.id
    )
  end
end
# Now all events automatically include request_id and shop_id!
To control how events are emitted, you register custom subscribers. Subscribers must implement an emit method that receives the event hash:
# config/initializers/event_subscribers.rb
class DatadogSubscriber
  def emit(event)
    # event is a hash with:
    # - :name (String)
    # - :payload (Hash with symbolized keys)
    # - :tags (Hash)
    # - :context (Hash)
    # - :timestamp (nanoseconds)
    # - :source_location (Hash with :filepath, :lineno, :label)
    
    Datadog::Statsd.event(
      event[:name],
      event[:payload].to_json,
      tags: event[:tags]
    )
  end
end
Rails.event.subscribe(DatadogSubscriber.new)
💡 Pro Tip: Set up event context in your ApplicationController for request_id and user_id. Then every event you emit will automatically include that context. This makes debugging production issues so much easier—you can trace an entire request flow across services.
🚨 Gotcha: Don't go overboard with events. Focus on business-critical actions (signups, purchases, errors) not every line of code. Too many events = noise = useless logs.
3. Local CI: Run Your Full Pipeline Locally
Why It Matters
One of my biggest frustrations has been waiting 10 minutes for GitHub Actions to tell me I have a failing test. Then I fix it, push again, and wait another 10 minutes to discover I broke something else. Rails 8.1 adds a CI DSL that lets you define your entire CI pipeline in config/ci.rb and run it locally with bin/ci—catching issues before you even push.
The Rails team mentions that HEY's test suite of 30,000+ assertions runs in just 1m 23s on a Framework Desktop AMD Linux machine and 2m 22s on an M4 Max. That's faster than waiting for cloud CI to even start!
Setting It Up
# config/ci.rb
CI.run do
  step "Setup", "bin/setup --skip-server"
  step "Style: Ruby", "bin/rubocop"
  step "Security: Gem audit", "bin/bundler-audit"
  step "Security: Importmap audit", "bin/importmap audit"
  step "Security: Brakeman", "bin/brakeman --quiet --no-pager"
  step "Tests: Rails", "bin/rails test"
  step "Tests: System", "bin/rails test:system"
  step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
  # Optional: Integrate with GitHub CLI for PR signoffs
  if success?
    step "Signoff: Ready for merge", "gh signoff"
  else
    failure "Signoff: CI failed. Fix and try again.", "gh signoff"
  end
end
Now run your entire CI pipeline locally:
$ bin/ci
No more "fix tests" commits. Catch everything before pushing.
💡 Pro Tip: The gh signoff integration is clever—it requires a passing local CI run before PRs can be merged. Combined with branch protection rules, this ensures nobody merges broken code.
🚨 Gotcha: This works best with fast test suites. If your tests take 20+ minutes, developers will skip bin/ci. Invest in test speed first, then enforce local CI.
4. Association Deprecations: Refactoring Legacy Code Safely
The Problem
If you've inherited a legacy Rails app (who hasn't?), you know the pain of removing old associations. Is this has_many still used? If I delete it, what breaks? Grepping for the association name doesn't catch all the indirect usage through preloading, nested attributes, or other associations.
The Solution
Rails 8.1 adds a deprecated: option for associations that tracks all usage patterns:
class Author < ApplicationRecord
  has_many :posts, deprecated: true
end
Now when code calls author.posts anywhere—directly, through preloading, nested attributes, or any other method—you get a deprecation warning:
DEPRECATION WARNING: posts association is deprecatedRails supports three reporting modes:
# config/environments/development.rb
config.active_record.deprecated_associations = :warn   # default, logs warning
config.active_record.deprecated_associations = :raise  # raises exception
config.active_record.deprecated_associations = :notify # sends to ActiveSupport::Notifications
The clever part? Rails tracks all usage, not just direct calls. This includes:
author.posts(explicit)author.preload(:posts)(query optimization)- Nested attributes
 - Association callbacks
 - Indirect usage through other associations
 
💡 Pro Tip: Use :raise mode in your test suite to catch all deprecated usage immediately. Use :warn in staging to log production-like usage patterns. Then confidently remove the association when warnings stop appearing. This would have saved me weeks of refactoring time on legacy codebases.
🎯 Quick Hits: More Rails 8.1 Wins
Beyond the major features, Rails 8.1 brings several smaller improvements worth mentioning:
Native Markdown Rendering: Rails now handles Markdown natively through Action View. If you're building a blog or documentation site, you can use render markdown: @object without external gems. It's basic but covers common use cases.
Kamal Improvements: If you're using Kamal for deployments, there's a new rails credentials:fetch command for pulling secrets from encrypted credentials, and Kamal 2.8 now supports registry-free deployments for simple setups (though you'll still want a remote registry for production).
Verbose Redirects in Development: New Rails 8.1 apps have verbose redirect logging enabled by default (config.action_dispatch.verbose_redirect_logs = true), making it easier to track redirect chains during development. Add this to your config/development.rb if upgrading an existing app.
Alphabetically Sorted Schema: The schema.rb file now sorts table columns alphabetically, making diffs cleaner and more predictable when reviewing changes.
Should You Upgrade?
Upgrade if:
- ✅ You have long-running background jobs
 - ✅ You need structured production logs
 - ✅ You want local CI with 
bin/ci - ✅ You're on Ruby 3.3+
 
Wait if:
- ⏸️ You're on Ruby < 3.3 (upgrade Ruby first)
 - ⏸️ Critical gems aren't Rails 8.1 compatible yet
 - ⏸️ You're in a code freeze
 - ⏸️ Current version works fine and you're not hitting pain points
 
Wrapping Up
Rails 8.1 brings useful improvements for job reliability, production debugging, and developer workflow. The standout features:
- 🎯 Active Job Continuations – jobs resume from checkpoints
 - 📊 Structured Event Reporting – machine-readable logs
 - 🚀 Local CI – catch failures before pushing
 - 🔧 Association Deprecations – track usage before removal
 
For most teams on Rails 8.0 or 7.x with Ruby 3.3+, this is a straightforward upgrade.
Have you upgraded to Rails 8.1 yet? I'm curious about your experience. Connect on X or LinkedIn!
Resources:
