How The Rails Request Lifecycle Works

Learn how the Rails request lifecycle works, from the web server and middleware to routing, controllers, views, and the response.


By Jean Emmanuel Cadet • 23 min read

How the Rails Request Lifecycle Works
Share with friends

When you type a URL and press Enter, a lot happens before your Rails controller runs a single line of code, and a lot more happens before the browser paints anything. The Rails request lifecycle is the path an HTTP request takes through your application, from the network to the web server, through middleware, routing, a controller, the models and database, a view, and back out as an HTTP response.

If you understand this flow, debugging gets easier. You know where to look when a route does not match, a cookie disappears, a callback halts a request, or a redirect loops. This guide walks through the Rails request flow one stage at a time, with code you can copy and adapt.


What Is the Rails Request Lifecycle?

The Rails request lifecycle (also called the Rails request cycle or request response cycle) is the sequence of steps Rails follows to turn an incoming HTTP request into an HTTP response.

Here is the short version:

Browser
|
| HTTP request
v
Web server / app server (Thruster, Puma)
|
v
Rack middleware stack
|
v
Router (config/routes.rb)
|
v
Controller (callbacks, params, action)
|
v
Models and database (Active Record)
|
v
View rendering (template, layout, partials)
|
v
Middleware stack again (on the way out)
|
v
HTTP response back to the browser

Every stage has a single job:

  • The server accepts the connection and hands Rails a request.
  • Middleware wraps the request with cross-cutting features like cookies, sessions, logging, and error handling.
  • The router decides which controller and action should handle the request.
  • The controller coordinates the work, talks to models, and decides what to respond with.
  • Models read and write data through Active Record.
  • Views turn data into HTML, or you render JSON or another format.
  • The response travels back through the middleware and out to the client.

The rest of this article explains each stage in detail and then follows one real request from start to finish.


How an HTTP Request Reaches a Rails Application

An HTTP request is just text sent over a TCP connection. A simple request to view a blog post looks like this:

GET /posts/42 HTTP/1.1
Host: example.com
Accept: text/html
Cookie: _my_app_session=abc123
User-Agent: Mozilla/5.0

It contains four important pieces:

  • A method (GET) that says what the client wants to do.
  • A path (/posts/42) that says which resource it wants.
  • Headers such as Host, Accept, and Cookie.
  • An optional body, used by POST, PATCH, and PUT requests.

Before Rails sees any of this, DNS resolves the domain to an IP address, the browser opens a connection (with TLS for HTTPS), and the request is sent to your server.

The Role of the Web Server and Application Server

Rails does not listen on a network port by itself. It relies on servers that speak HTTP and hand requests to Rails through Rack, the Ruby interface between web servers and Ruby web applications.

In a typical modern Rails deployment, you will see two layers:

  • A front-facing web server or proxy, such as Nginx or Thruster in the default Rails 8 Docker setup. It handles TLS, static file caching, compression, and buffering slow clients.
  • An application server, which is Puma by default. It runs your Ruby code, using threads and optionally multiple worker processes to handle several requests at once.

Puma converts the raw HTTP request into a Rack env, a Ruby hash that describes the request. Then it calls your application:

# The Rack contract: any object that responds to call(env)
# and returns [status, headers, body]
class HelloApp
def call(env)
[200, { "content-type" => "text/plain" }, ["Hello from Rack"]]
end
end

Your Rails application is a Rack application. You can see this in config.ru:

# config.ru
require_relative "config/environment"

run Rails.application

Rails.application responds to call(env). That single method call is where the Rails request lifecycle begins.


How Rails Receives and Processes the Request

When Puma calls Rails.application.call(env), Rails builds the machinery it needs to handle the request. Two objects matter most:

  • ActionDispatch::Request, a wrapper around the Rack env that gives you friendly methods like request.path, request.headers, request.format, and request.remote_ip.
  • ActionDispatch::Response, which collects the status, headers, and body you will send back.

You can inspect the request object from any controller:

class DebugController < ApplicationController
def show
render json: {
method: request.method,
path: request.path,
format: request.format.to_s,
ip: request.remote_ip,
user_agent: request.user_agent
}
end
end

Before the request reaches the router, it passes through the middleware stack.


The Role of Middleware in the Rails Request Lifecycle

Middleware is a chain of small Rack components. Each one receives the request, can do work before passing it down the chain, and can do more work after the rest of the chain returns a response. Think of it as layers of an onion: the request travels inward, and the response travels back outward.

You can list your app's middleware with:

bin/rails middleware

The exact list varies by Rails version and configuration, but it includes components like these:

  • ActionDispatch::HostAuthorization blocks requests for hosts you have not allowed.
  • ActionDispatch::Static serves static files from public/ when configured to do so.
  • ActionDispatch::RequestId assigns a unique ID to each request for tracing in logs.
  • ActionDispatch::RemoteIp works out the real client IP address.
  • Rails::Rack::Logger logs the start and end of each request.
  • ActionDispatch::ShowExceptions and ActionDispatch::DebugExceptions convert exceptions into error responses.
  • ActionDispatch::Cookies reads and writes cookies.
  • ActionDispatch::Session::CookieStore loads and saves the session.
  • ActionDispatch::Flash manages flash messages.
  • Rack::ETag and Rack::ConditionalGet support conditional HTTP caching.

A Simple Custom Middleware Example

A middleware is any object with an initialize(app) method and a call(env) method. This one measures how long the rest of the stack takes and adds a header:

# app/middleware/response_timer.rb
class ResponseTimer
def initialize(app)
@app = app
end

def call(env)
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

status, headers, body = @app.call(env)

elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
headers["x-response-time-ms"] = elapsed_ms.to_s

[status, headers, body]
end
end

Register it in your application config:

# config/application.rb
config.middleware.use ResponseTimer

Expected behavior: every response now includes an x-response-time-ms header. The code before @app.call(env) runs on the way in, and the code after it runs on the way out.

Use middleware for concerns that apply to every request regardless of route, such as request timing, IP filtering, or custom headers. Use controller callbacks for logic that belongs to specific controllers or actions.


How the Rails Router Matches a Request

After the middleware stack, the request reaches the router. The router compares the HTTP method and path against the routes defined in config/routes.rb and finds the first match.

# config/routes.rb
Rails.application.routes.draw do
root "posts#index"

resources :posts do
resources :comments, only: [:create, :destroy]
end

get "/about", to: "pages#about"
end

The line resources :posts generates seven standard routes. You can see them with:

bin/rails routes -g posts
   Prefix Verb   URI Pattern               Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy

For our example request, GET /posts/42 matches posts#show. The router now knows the controller class (PostsController) and the action (show).

Order matters. Rails checks routes from top to bottom and stops at the first match, so a broad route placed too early can swallow requests meant for a more specific one. If you want to go deeper into route design, see how Rails routing works.

How Route Parameters Are Extracted

Dynamic segments in a route, like :id, are captured from the URL and stored as parameters. For GET /posts/42?ref=newsletter, Rails builds a params hash like this:

{
"controller" => "posts",
"action" => "show",
"id" => "42",
"ref" => "newsletter"
}

Notice two things. The route segment (id) and the query string (ref) end up in the same params object. Also, values from the URL are always strings, so "42" is not the integer 42.


What Happens Between the Route and the Controller Action

Once the router picks a controller and action, Rails instantiates the controller and calls process(:show). From here, Action Controller does several things in order:

  1. Creates a new instance of PostsController for this request.
  2. Attaches the request, response, and params.
  3. Runs the callback chain (before_action, around_action).
  4. Calls your action method.
  5. Renders a response if you did not explicitly do so.
  6. Runs after_action callbacks.

A new controller instance is created for every request, so instance variables set in one request never leak into another.


Controller Callbacks: before_action, after_action, and around_action

Callbacks let you run code before, after, or around an action. They keep controllers tidy by pulling out repeated logic.

class PostsController < ApplicationController
before_action :set_post, only: %i[show edit update destroy]
around_action :log_action_time
after_action :track_view, only: :show

def show
# @post is already loaded by set_post
end

private

def set_post
@post = Post.find(params[:id])
end

def log_action_time
started = Time.current
yield
ensure
Rails.logger.info("Action took #{((Time.current - started) * 1000).round}ms")
end

def track_view
Rails.logger.info("Post #{@post.id} viewed")
end
end

The execution order for show is:

  1. set_post (before)
  2. log_action_time starts (around, before yield)
  3. show action runs, and the view renders
  4. log_action_time finishes (around, after yield)
  5. track_view (after)

Important behaviors to remember:

  • If a before_action calls render or redirect_to, the chain halts. The action never runs.
  • only: and except: limit which actions a callback applies to.
  • An around_action must call yield, or the action will not run.
  • after_action callbacks run after the action and its rendering, but before the response is sent to the client, so they can still adjust headers.

How Rails Handles Parameters and params

Everything the client sends, whether it is a route segment, query string, or form body, arrives in params. Rails parses each format for you:

  • Query strings and form bodies (application/x-www-form-urlencoded) become nested hashes.
  • JSON bodies (application/json) are parsed and merged into params.
  • Multipart uploads become ActionDispatch::Http::UploadedFile objects.

Never pass raw params to a model. Use strong parameters. In Rails 8, the preferred method is params.expect:

class PostsController < ApplicationController
def create
@post = Current.user.posts.build(post_params)

if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end

private

def post_params
params.expect(post: [:title, :body, :published])
end
end

Expected behavior: params.expect requires a post key and permits only the listed attributes. If post is missing, Rails raises ActionController::ParameterMissing, which becomes a 400 Bad Request response. Unpermitted keys are ignored, which protects you from mass assignment problems.


Where Authentication and Authorization Fit

Authentication answers "who is making this request?" Authorization answers "are they allowed to do this?" Both usually happen in before_action callbacks, early in the controller stage, so that unauthorized requests never touch your business logic or database.

class ApplicationController < ActionController::Base
before_action :require_authentication

private

def require_authentication
redirect_to new_session_path, alert: "Please sign in." unless Current.user
end
end
class PostsController < ApplicationController
before_action :set_post, only: %i[edit update destroy]
before_action :authorize_owner!, only: %i[edit update destroy]

private

def authorize_owner!
head :forbidden unless @post.user_id == Current.user.id
end
end

A typical browser-based flow works like this:

  1. The user signs in, and Rails stores an identifier in the session.
  2. On each later request, the session middleware loads the session.
  3. A before_action uses the session to find the current user.
  4. If there is no valid user, Rails redirects to the sign-in page. If the user lacks permission, Rails returns 403 Forbidden.

Rails 8 ships with a built-in authentication generator that follows this pattern. If you are choosing between approaches, read Devise vs custom authentication in Rails. For token-based APIs, where there is no browser session, the same before_action idea applies, but the credential comes from an Authorization header instead. The Rails API authentication with JWT guide covers that in detail.


How the Controller Interacts with Models and the Database

The controller should coordinate, not contain business logic. It asks models for data, passes user input to them, and decides what to render.

class PostsController < ApplicationController
def index
@posts = Post.published.includes(:user).order(created_at: :desc).limit(20)
end

def show
@post = Post.find(params[:id])
end
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy

validates :title, presence: true

scope :published, -> { where(published: true) }
end

How Active Record Queries Are Executed

Active Record queries are lazy. Building a relation does not hit the database. The SQL runs only when you actually need the data.

posts = Post.published.order(created_at: :desc)  # no SQL yet
posts.each { |post| puts post.title } # SQL runs here

Common triggers that execute a query include each, to_a, first, find, count, pluck, and calling a relation in a view loop. When a query runs, Active Record does this:

  1. Builds an SQL string from the relation.
  2. Checks out a connection from the connection pool.
  3. Sends the SQL to the database and waits for results.
  4. Instantiates model objects from the returned rows.

Because the relation is lazy, the query often runs during view rendering, not in the controller. That is why an N+1 problem often shows up in the view, even though the fix (includes) belongs in the controller.

For our GET /posts/42 request, Post.find(params[:id]) produces roughly:

SELECT "posts".* FROM "posts" WHERE "posts"."id" = 42 LIMIT 1

If no row exists, find raises ActiveRecord::RecordNotFound, which Rails turns into a 404 Not Found in production. Errors get their own section below.


How Rails Renders Views

After the action method finishes, Rails needs a response. If you did not call render, redirect_to, or head, Rails performs an implicit render. It looks for a template that matches the controller and action, for example app/views/posts/show.html.erb.

<%# app/views/posts/show.html.erb %>
<article>
<h1><%= @post.title %></h1>
<p class="meta">By <%= @post.user.name %></p>
<div><%= simple_format(@post.body) %></div>
</article>

<section>
<h2>Comments</h2>
<%= render @post.comments %>
</section>

Instance variables assigned in the controller, like @post, are copied into the view so the template can use them. Rails also picks the template based on the request format. An HTML request looks for show.html.erb, while a JSON request could use show.json.jbuilder.

Templates, Layouts, and Partials

Three pieces combine to produce the final HTML:

  • Template: the main view for the action, such as posts/show.html.erb.
  • Layout: a wrapper shared by many templates, normally app/views/layouts/application.html.erb. The template output is inserted where the layout calls yield.
  • Partial: a reusable fragment, named with a leading underscore, such as _comment.html.erb.
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "My App" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag :app %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= render "shared/flash" %>
<%= yield %>
</body>
</html>
<%# app/views/comments/_comment.html.erb %>
<div id="<%= dom_id(comment) %>">
<strong><%= comment.user.name %></strong>
<p><%= comment.body %></p>
</div>

The line render @post.comments renders the _comment partial once for each comment, using collection rendering. Rails first renders the template, then wraps it in the layout, and the result becomes the response body.


How Rails Generates the Final HTTP Response

An HTTP response has three parts: a status code, headers, and a body.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
ETag: W/"5d41402abc4b2a76b9719d911017c592"
Set-Cookie: _my_app_session=...; path=/; HttpOnly; SameSite=Lax

<!DOCTYPE html>
<html>...</html>

After the controller finishes, Rails hands the response back up through the middleware stack. On the way out, middleware can add or modify things. For example, the cookie and session middleware write Set-Cookie headers, and Rack::ETag may compute an ETag so the browser can make conditional requests later.

Status Codes, Headers, and Bodies in Practice

You will use a small set of status codes most of the time:

  • 200 OK: success with a body.
  • 201 Created: a resource was created (common in APIs).
  • 204 No Content: success with no body.
  • 301 and 302/303: redirects.
  • 400 Bad Request: malformed input or missing params.
  • 401 Unauthorized: not authenticated.
  • 403 Forbidden: authenticated but not allowed.
  • 404 Not Found: no such resource.
  • 422 Unprocessable Entity: validation failed, or the CSRF token was invalid.
  • 500 Internal Server Error: an unhandled exception.

You can set them explicitly:

render :new, status: :unprocessable_entity
head :no_content
render json: { error: "Not found" }, status: :not_found
response.headers["X-Custom-Header"] = "hello"

Responding with HTML, JSON, Redirects, and Other Formats

The same action can respond in different ways depending on the request. Rails uses respond_to to pick based on the Accept header or the URL extension.

def show
@post = Post.find(params[:id])

respond_to do |format|
format.html # renders show.html.erb
format.json { render json: @post } # renders JSON
format.pdf { send_data generate_pdf(@post), type: "application/pdf" }
end
end

HTML Responses

The default browser response. Rails renders the template inside the layout and sends Content-Type: text/html.

JSON Responses

render json: serializes an object and sets Content-Type: application/json. For a full API workflow, see how to build a Rails JSON API.

render json: { id: @post.id, title: @post.title }, status: :ok

Other Response Types

  • send_file and send_data return files for download.
  • head :no_content returns only a status and headers.
  • render plain: "OK" returns plain text.

Redirects and Why They Trigger a New Request

A redirect does not send a page. It sends a status code (usually 302 or 303) and a Location header. The browser then makes a brand new request to that URL.

def create
@post = Current.user.posts.build(post_params)

if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end

On success, the browser receives:

HTTP/1.1 302 Found
Location: https://example.com/posts/43

Then the browser sends GET /posts/43, and the whole request lifecycle starts again from the beginning. This is the Post/Redirect/Get pattern. It prevents duplicate form submissions when a user refreshes the page, because the browser's last action is a harmless GET.

Notice that a failed save uses render :new, not a redirect. The form is re-rendered in the same request so the submitted values and validation errors are still available. Use status: :unprocessable_entity so Turbo and browsers treat it as a failed submission.


Sessions, Cookies, and Flash Messages During a Request

HTTP is stateless, so Rails uses cookies to carry state between requests.

Cookies

Cookies are small key/value pairs stored by the browser and sent with every request to your domain. Rails gives you a cookies object:

cookies[:theme] = "dark"                             # plain cookie
cookies.signed[:user_id] = 42 # tamper-proof
cookies.encrypted[:token] = "secret" # encrypted
cookies[:theme] = { value: "dark", expires: 1.year.from_now, httponly: true }

Sessions

The session is a hash that persists across requests. By default, Rails stores it in an encrypted cookie:

session[:cart_id] = @cart.id
Cart.find(session[:cart_id])

During the lifecycle:

  1. On the way in, the session middleware reads the cookie, decrypts it, and loads the hash.
  2. Your controller reads and writes session.
  3. On the way out, the middleware serializes any changes and adds a Set-Cookie header.

Keep sessions small. Cookies have a size limit of around 4 KB, and they are sent on every request.

Flash Messages

The flash is a special part of the session that lives for exactly one more request. It is designed to work with redirects:

redirect_to @post, notice: "Post created."
  • Request 1 sets flash[:notice] and responds with a redirect.
  • Request 2 (the redirected GET) can read flash[:notice] and display it.
  • After request 2 finishes, the flash is cleared.

If you want a message to show in the current request without a redirect, use flash.now:

flash.now[:alert] = "Please fix the errors below."
render :new, status: :unprocessable_entity

How CSRF Protection Fits Into the Lifecycle

Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a logged-in user's browser into sending a state-changing request to your app. Because browsers automatically attach cookies, your app could mistake the forged request for a legitimate one.

Rails defends against this with an authenticity token. Here is how it fits the lifecycle:

  1. When rendering a form, Rails embeds a token in a hidden field. The layout also includes it via csrf_meta_tags.
  2. When the form is submitted with POST, PATCH, PUT, or DELETE, the token is sent back.
  3. Early in the controller stage, Rails compares the submitted token with the one stored in the session.
  4. If they do not match, Rails raises ActionController::InvalidAuthenticityToken, which results in a 422 response.
<%= form_with model: @post do |form| %>
<%# Rails adds a hidden authenticity_token field automatically %>
<%= form.text_field :title %>
<%= form.submit %>
<% end %>

GET requests are not checked, which is why you should never change data in a GET action.

Rails enables this protection by default for controllers that inherit from ActionController::Base. For token-authenticated JSON APIs, where the client does not rely on browser cookies, you normally use ActionController::API or skip CSRF checks for those endpoints:

class Api::BaseController < ActionController::API
# No CSRF protection needed when authentication uses an Authorization header
end

Only skip CSRF protection when the endpoint does not authenticate through cookies.


How Exceptions and Errors Are Handled

Errors can happen at any stage: a missing record, a validation failure, an authorization problem, or a bug in your code. Rails handles them in layers.

Layer 1: Rescue in the Controller

Use rescue_from to handle known exceptions in one place:

class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request

private

def not_found
respond_to do |format|
format.html { render "errors/not_found", status: :not_found }
format.json { render json: { error: "Not found" }, status: :not_found }
end
end

def bad_request
head :bad_request
end
end

Expected behavior: a missing post shows your custom 404 page for HTML requests and a JSON error for API requests, instead of a generic error page.

Layer 2: Exception Middleware

If an exception is not rescued in the controller, it travels back up the middleware stack. Two middleware components deal with it:

  • ActionDispatch::DebugExceptions renders the detailed debug page in development.
  • ActionDispatch::ShowExceptions converts the exception into an HTTP response in production, using an exceptions app.

Rails maps common exceptions to status codes. For example, ActiveRecord::RecordNotFound becomes 404, ActionController::RoutingError becomes 404, ActionController::ParameterMissing becomes 400, and ActionController::InvalidAuthenticityToken becomes 422. Unknown exceptions become 500.

Layer 3: Error Pages

In production, if you have not customized anything, Rails serves the static files in public/, such as 404.html, 422.html, and 500.html. If you want dynamic error pages, point Rails at your own routes:

# config/application.rb
config.exceptions_app = routes
# config/routes.rb
match "/404", to: "errors#not_found", via: :all
match "/500", to: "errors#internal_server_error", via: :all

Static error pages are a good choice for 500 errors because they keep working even if your app or database is broken.


Turbo Requests vs Normal Full-Page Requests

Modern Rails applications include Turbo by default. Turbo changes how the browser makes requests, but the server-side lifecycle stays almost the same.

A Normal Full-Page Request

Without Turbo, clicking a link makes the browser discard the current page, request a new one, and load everything from scratch, including CSS and JavaScript.

A Turbo Drive Request

With Turbo Drive, Turbo intercepts link clicks and form submissions. It sends the request using fetch, then replaces the <body> of the current page with the new response and merges the <head>. The JavaScript and CSS are not reloaded. On the server, this is still a normal Rails request that goes through routing, middleware, controllers, and views, and returns a full HTML document.

Turbo Frames

A Turbo Frame limits an update to one part of the page. When a request comes from inside a frame, Turbo sends a Turbo-Frame header with the frame's ID. Rails uses this to render the response without the full layout, and Turbo extracts the matching frame from the response.

<%= turbo_frame_tag dom_id(@post, :details) do %>
<h2><%= @post.title %></h2>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# app/views/posts/edit.html.erb %>
<%= turbo_frame_tag dom_id(@post, :details) do %>
<%= render "form", post: @post %>
<% end %>

Clicking "Edit" sends a request to posts#edit. The controller code does not change. The response only needs a matching frame, and Turbo swaps just that region. For a full walkthrough, read how Turbo Frames work in Ruby on Rails.

Turbo Streams

A response with Content-Type: text/vnd.turbo-stream.html can contain instructions to append, replace, or remove specific elements:

def create
@comment = @post.comments.build(comment_params)

if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
else
render :new, status: :unprocessable_entity
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>

Practical Differences to Remember

  • Turbo form submissions expect a redirect after success and a 422 render on validation failure. Without the right status codes, Turbo may not update the page.
  • The lifecycle on the server is the same: router, controller, model, view. Only the client behavior and the response format change.
  • Redirects still cause a second request, but Turbo follows them via fetch and updates the page without a full reload.

An End-to-End Example: Following One Request

Let us trace a real request from start to finish. A signed-in user opens the form to create a comment, submits it, and sees the updated post.

The route:

resources :posts do
resources :comments, only: :create
end

The controller:

class CommentsController < ApplicationController
before_action :require_authentication
before_action :set_post

def create
@comment = @post.comments.build(comment_params.merge(user: Current.user))

if @comment.save
redirect_to @post, notice: "Comment added."
else
render "posts/show", status: :unprocessable_entity
end
end

private

def set_post
@post = Post.find(params[:post_id])
end

def comment_params
params.expect(comment: [:body])
end
end

The request:

POST /posts/42/comments HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Cookie: _my_app_session=abc123

authenticity_token=...&comment[body]=Great+article

What happens, step by step:

  1. Server: Thruster and Puma accept the connection and build the Rack env.
  2. Middleware (in): The host check passes. A request ID is assigned. Cookies are parsed, and the session is loaded from _my_app_session. The flash from any earlier request is loaded.
  3. Router: POST /posts/42/comments matches comments#create. Params are post_id: "42" plus the form fields.
  4. Controller setup: A new CommentsController is created. Rails verifies the CSRF authenticity token.
  5. Callbacks: require_authentication confirms the user is signed in. set_post runs Post.find("42"), which executes a SQL query.
  6. Action: params.expect filters the comment attributes. @post.comments.build creates the object, and @comment.save runs validations and an INSERT in a transaction.
  7. Response: The save succeeds, so redirect_to @post sets a 302 status, a Location: /posts/42 header, and flash[:notice].
  8. Middleware (out): The flash and session are written into a Set-Cookie header. The response is logged.
  9. Browser: Receives the 302 and sends GET /posts/42.
  10. Second lifecycle: The router matches posts#show. The controller loads the post and comments, and the view renders inside the layout. The flash notice is displayed, then cleared.
  11. Final response: The browser receives 200 OK with the HTML body.

Here is the whole flow in one diagram:

POST /posts/42/comments
-> Puma
-> Middleware (cookies, session, flash, request id)
-> Router: comments#create
-> CSRF check
-> before_action: require_authentication, set_post
-> Post.find (SQL SELECT)
-> comment.save (SQL INSERT)
-> redirect_to post (302 + Set-Cookie)
<- Middleware (write session and flash)
<- Browser receives 302

GET /posts/42
-> Middleware -> Router: posts#show
-> Controller -> Active Record -> View + Layout
<- 200 OK with HTML

If you ever get lost while debugging, this stage-by-stage view tells you where to look. A missing route points to routing. A missing session points to middleware. A wrong or missing record points to the model or a before_action. Bad HTML points to the view or layout.


How Everything Fits Together

Each part of the Rails stack has a clear responsibility:

  • Middleware handles cross-cutting concerns for every request, such as cookies, sessions, logging, and error conversion.
  • Routing maps the method and path to a controller and action, and extracts route parameters.
  • Controllers run callbacks, check authentication and authorization, read params, call models, and choose a response.
  • Models encapsulate data access and business rules through Active Record.
  • Views turn data into HTML using templates, layouts, and partials.
  • Responses package a status code, headers, and a body, then travel back out through the middleware.

Once you can name the stage a problem belongs to, you can debug it much faster.


Practical Tips for Debugging the Request Lifecycle

  • Run bin/rails routes to confirm which route and action a URL should hit.
  • Run bin/rails middleware to see the middleware stack in order.
  • Check log/development.log. Each request shows the route, controller, action, parameters, SQL queries, rendered templates, and final status.
  • Use request.headers, params, and session in a debugger or temporary log line to see exactly what Rails received.
  • Look at the browser's Network tab to see redirects, status codes, and headers.

A typical development log entry looks like this:

Started GET "/posts/42" for 127.0.0.1 at 2026-09-27 10:15:00 +0000
Processing by PostsController#show as HTML
Parameters: {"id"=>"42"}
Post Load (0.4ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 42 LIMIT 1
Rendering posts/show.html.erb within layouts/application
Rendered posts/show.html.erb within layouts/application (Duration: 3.1ms)
Completed 200 OK in 12ms

Read this log from top to bottom, and you can see the request lifecycle happening in real time.


Conclusion

The Rails request lifecycle is a clear pipeline: the server receives the HTTP request, middleware prepares it, the router picks a controller action, callbacks and params set up the work, models talk to the database, views render the output, and Rails sends a response back through the middleware to the browser. Redirects start the cycle again, sessions and cookies carry state between cycles, CSRF tokens protect form submissions, and Turbo changes how the browser sends and applies requests without changing the server-side flow.

The next time something breaks, ask yourself which stage the request was in. That single question will usually point you to the right file.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.