Rails 8 Action Callbacks Deep Dive

Controller actions often share common logic: authentication checks, loading resources, setting headers, or logging requests. Scattering this code across every action creates duplication and maintenance headaches. Rails 8 action callbacks solve this elegantly.

Understanding the Callback Chain

Action callbacks run at specific points in the request lifecycle. Rails provides three types:

  • before_action - Runs before the action method executes
  • after_action - Runs after the action completes (but before response is sent)
  • around_action - Wraps the action, running code before and after

The callback chain executes in order of definition. If any before_action halts the chain (by rendering or redirecting), subsequent callbacks and the action itself never run.

Before Actions: The Workhorses

Most controller callbacks are before_action filters. They handle authentication, authorization, and resource loading.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :authenticate_user!
  before_action :set_article, only: %i[show edit update destroy]
  before_action :authorize_article, only: %i[edit update destroy]

  def index
    @articles = Article.published.recent
  end

  def show
    # @article already loaded by set_article
  end

  def edit
    # @article loaded and authorized
  end

  def update
    if @article.update(article_params)
      redirect_to @article, notice: "Article updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @article.destroy
    redirect_to articles_path, notice: "Article deleted."
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def authorize_article
    unless @article.editable_by?(Current.user)
      redirect_to @article, alert: "Not authorized."
    end
  end

  def article_params
    params.require(:article).permit(:title, :body, :published_at)
  end
end

The only: and except: options control which actions trigger the callback. Use only: when the callback applies to few actions; use except: when it applies to most.

Halting the Callback Chain

Callbacks can halt execution by calling render, redirect_to, or head. This prevents the action from running—useful for authentication and authorization.

# app/controllers/concerns/api_authentication.rb
module ApiAuthentication
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_api_request
  end

  private

  def authenticate_api_request
    token = request.headers["Authorization"]&.remove("Bearer ")
    
    @current_api_user = User.find_by_api_token(token)
    
    unless @current_api_user
      render json: { error: "Invalid or missing API token" }, status: :unauthorized
      # Chain halts here - action never executes
    end
  end

  def current_api_user
    @current_api_user
  end
end

When render executes in a callback, Rails sets a flag indicating a response exists. Subsequent callbacks check this flag and skip execution. The action method never runs.

Around Actions for Timing and Transactions

Around actions wrap the entire action execution. They receive a block representing the action and remaining callbacks. This pattern works well for timing, database transactions, or context setup.

# app/controllers/concerns/request_timing.rb
module RequestTiming
  extend ActiveSupport::Concern

  included do
    around_action :log_request_timing
  end

  private

  def log_request_timing
    start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    
    yield  # Execute the action
    
    duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
    
    Rails.logger.info(
      "[Timing] #{controller_name}##{action_name} " \
      "completed in #{(duration * 1000).round(2)}ms"
    )
  end
end

The yield statement is crucial—it executes the action. Code before yield runs first; code after runs when the action completes. Forgetting yield means the action never executes.

After Actions for Cleanup and Logging

After actions run once the action completes but before the response reaches the client. They work well for logging, analytics, or cleanup that shouldn't block the response.

# app/controllers/downloads_controller.rb
class DownloadsController < ApplicationController
  before_action :set_document
  after_action :track_download, only: :show

  def show
    send_file @document.file.path,
              filename: @document.filename,
              type: @document.content_type
  end

  private

  def set_document
    @document = Document.find(params[:id])
  end

  def track_download
    # Runs after send_file but doesn't block it
    DownloadTrackingJob.perform_later(
      document_id: @document.id,
      user_id: Current.user&.id,
      ip_address: request.remote_ip,
      user_agent: request.user_agent
    )
  end
end

Note that after_action callbacks have access to the response but cannot modify it in ways that affect what the client receives—the response is already committed.

Callback Inheritance and Skipping

Callbacks defined in parent controllers apply to all child controllers. Override this behavior with skip_before_action, skip_after_action, or skip_around_action.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :set_current_request_details

  private

  def set_current_request_details
    Current.request_id = request.request_id
    Current.user_agent = request.user_agent
    Current.ip_address = request.remote_ip
  end
end

# app/controllers/public_pages_controller.rb
class PublicPagesController < ApplicationController
  skip_before_action :authenticate_user!

  def home
    @featured_articles = Article.featured.limit(5)
  end

  def about
  end

  def pricing
  end
end

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  skip_before_action :authenticate_user!, only: %i[new create]

  def new
  end

  def create
    # Login logic
  end

  def destroy
    # Logout requires authentication
    reset_session
    redirect_to root_path
  end
end

The only: and except: options work with skip methods too. This granular control allows precise callback management without duplicating logic.

Conditional Callbacks

Sometimes callbacks should run based on runtime conditions. Use if: and unless: options with a method name, proc, or lambda.

# app/controllers/api/base_controller.rb
module Api
  class BaseController < ApplicationController
    skip_before_action :verify_authenticity_token
    before_action :enforce_json_format
    before_action :log_api_request, if: -> { Rails.env.production? }
    after_action :track_api_usage, unless: :health_check?

    private

    def enforce_json_format
      request.format = :json unless request.format.json?
    end

    def log_api_request
      Rails.logger.info(
        "[API] #{request.method} #{request.path} " \
        "user=#{current_api_user&.id}"
      )
    end

    def track_api_usage
      ApiUsageTracker.record(
        endpoint: "#{controller_name}##{action_name}",
        user_id: current_api_user&.id,
        response_status: response.status
      )
    end

    def health_check?
      controller_name == "health" && action_name == "show"
    end
  end
end

Common Callback Pitfalls

Several patterns cause problems with callbacks:

  • Too many callbacks - Long callback chains obscure controller flow. Consider extracting logic into service objects when callbacks exceed 4-5.
  • Order dependencies - Callbacks that depend on other callbacks' side effects create fragile code. Document dependencies clearly.
  • Hidden redirects - Callbacks that redirect without obvious naming (like ensure_subscription) confuse developers debugging request flow.
  • Expensive after_actions - Heavy processing in after_action delays response delivery. Use background jobs instead.

Summary

Action callbacks eliminate duplication and centralize cross-cutting concerns. Use before_action for authentication, authorization, and resource loading. Reserve around_action for timing and transactions. Apply after_action for logging and lightweight tracking. Leverage skip_*_action methods to override inherited callbacks precisely. Keep callback chains short and their purposes obvious from naming.

10 claps
← Back to Blog