You know the story starts in config.ru: it calls run Rails.application, and a moment later your PostsController#show runs. But between those two lines sits everything you never had to think about — roughly twenty middlewares, a router that compiles your routes into a finite-state machine, and a dispatcher that turns params[:controller] into a Ruby class. When a cookie is missing, when Rack::ETag buffers a streaming response, or when a route mysteriously 404s with X-Cascade: pass, the bug lives exactly in that invisible middle. Even rails routes only shows you the “what” — a sorted table — never the “how”: why recognition order matters, why some requests skip pattern matching entirely, why a HEAD request sometimes behaves like GET.
The second pain point is ordering. Rails inserts Cookies and the session store outside the router on purpose, so a 404 from the router still gets cookies. It puts ShowExceptions above DebugExceptions so production gets a clean error page while development gets a stack trace. None of this is documented as behavior; it is just the sequence in one Ruby file. If you delete, swap, or reorder a middleware, you are editing that sequence blind. The same is true in every framework we have toured here — Django’s request path, Express’s middleware chain, Phoenix’s Plug chain, Laravel’s kernel — but Rails’ version is unusually centralized in a single class.
So we read it from the source. This is a tour of rails/rails at branch main (Rails 8.2.0.alpha, tree at commit afdc2eac, dated 2026-11-08), following one coherent request path: the Rack entry on Rails::Engine, the assembly of the default middleware stack, the Journey router’s recognition machinery, and the final hop into ActionController::Metal#dispatch. Every symbol and line number below was verified against the actual tree — nothing is quoted from memory or from guides.
Overview: one GET request crossing Rails — engine entry, the folded middleware chain, Journey recognition, and controller dispatch, left to right.
Reading the overview from left to right:
Rails::Engine#callis the Rack API of your application: it builds anActionDispatch::Request, merges the engine’senv_configinto the env, and delegates toapp.call(Engine#call, L608). Thatappis not a static object —Engine#appbuilds the whole middleware chain lazily, once, under a build lock (Engine#app, L591).- The chain is composed by a fold:
MiddlewareStack#buildtakes the frozen middleware list, reverses it, and injects each one around the endpoint (MiddlewareStack#build, L160). Each entry is justklass.new(app, ...)(Middleware#build, L42) — last registered wins the outermost position. - Which middlewares exist at all is decided by config in
DefaultMiddlewareStack#build_stack(build_stack, L14):Rack::Sendfileonly whenx_sendfile_headeris set (L33),ActionDispatch::Staticonly whenpublic_file_server.enabled(L39),Rack::MethodOverrideskipped in API mode (L58). - The executor wraps every request in
executor.run!and finalizes the state afterward through aRack::BodyProxy(Executor#call, L11) — this is what makes code reloading safe between requests. - The innermost app is the route set itself:
RouteSet#callnormalizesPATH_INFOand hands the request to the Journey router (RouteSet#call, L991). - Journey tries a precomputed hash before touching its state machine:
Routes#static_exact_mapmaps verb plus literal path straight to a route (static_exact_map, L94); only when that misses — or declines withX-Cascade: pass— doesJourney::Router#recognizerun (Journey::Router#serve, L29). - Recognition delegates candidate discovery to
GTG::Simulator#memos, a byte-wise walk of the route ASTs’ transition table (Simulator#memos, L21), then dispatches through the matched route’s app toRouteSet::Dispatcher#serve(Dispatcher#serve, L40) and finallyActionController::Metal#dispatch(Metal#dispatch, L319).
Detail view: how the stack is assembled at boot, the ordered default middlewares from Sendfile to ETag, Journey's matching internals, and the dispatch hop into your action.
The Rack Contract and Where It Really Begins
Everything below Rails::Engine#call is plain Rack: one env hash in, one [status, headers, body] triple out. The subtlety is that Engine#call first rebuilds the env through build_request, which merges env_config — extra keys like the engine’s routes — before the real chain sees it (Engine#call, L608, build_request, L850). An application is just an engine whose endpoint defaults to its RouteSet (Engine#endpoint, L603). And Rails::Application#default_middleware_stack is the single method that wires DefaultMiddlewareStack into the engine’s build (Application#default_middleware_stack, L764). That is the entire trick: your application object is a lazily-built composition of Rack-callable objects whose innermost member is the router.
Assembling the Default Stack
DefaultMiddlewareStack#build_stack is the most honest file in the framework: a single block that lists every default middleware in order, guarded by config. Reading top to bottom (build_stack, L14): HostAuthorization when hosts are configured (L17), optional SSL (L29), Rack::Sendfile (L33), ActionDispatch::Static from public/ (L39), the Executor (L54), Rack::Runtime (L57), Rack::MethodOverride unless API-only (L58), RequestId (L59), RemoteIp (L60), Rails::Rack::Logger which tags and announces each request (Rails::Rack::Logger#call, L20), ShowExceptions (L67) above DebugExceptions (L68), the reloader in dev (L75), Callbacks (L78), Cookies (L79), your session store (L85), Flash (L89), then the HTTP-caching trio Rack::Head (L94), Rack::ConditionalGet (L95), and Rack::ETag with a no-cache default (L96).
The order is the semantics. Exceptions middleware sits above Callbacks, so a raised error still renders an error page while callbacks have already run for the successful path. Cookies and the session wrap the router, so router-level 404s still clear cookies. And because Rack::ETag buffers bodies to compute digests, live-streaming controllers must remove it — the framework documents this dependency in its own comments.
Your own middlewares are not a separate concern. Engine#app merges everything you declared through config.middleware.use into the default stack before the fold happens (Engine#app, L595), so rails middleware shows one merged list, yours and Rails’. There is even an observability hook built into the fold: when a subscriber listens to the process_middleware.action_dispatch notification, each middleware is wrapped in an InstrumentationProxy that times every call (InstrumentationProxy#call, L64). Finally, MiddlewareStack#build reverses the list so that the last use call becomes the outermost middleware — which is why config.middleware.insert_before arguments feel backwards if you assume push order.
Journey: Recognition Without Regex Soup
At boot, RouteSet#draw clears the set, evaluates your routes block through the Mapper, and finalizes (RouteSet#draw, L553); each add_route lands in Journey::Routes, which partitions routes into anchored and custom collections (Routes#add_route, L75). Recognition is where Rails earns its speed. Routes#simulator compiles all anchored route ASTs into one GTG transition table (Routes#simulator, L68), and GTG::Simulator#memos walks the request path byte-wise — /, ., ? are the only static tokens (Simulator#memos, L21) — returning the routes memoized at accepting states. Journey::Router#recognize then filters those candidates by verb and constraints (Router#recognize, L60), with a HEAD fallback that retries the same candidates as GET (match_head_routes, L132), sorts by precedence, and extracts named captures into path_parameters as UTF-8 strings.
The fast path is the part rails routes never shows you. For routes that are anchored, single-verb, and all-literal — like GET /healthz — Routes#static_exact_map records a direct Hash lookup so the request never pays the simulator walk, the regexp match, or parameter extraction (static_exact_map, L94). If the static route’s app declines with X-Cascade: pass, Journey::Router#serve falls through to full recognition (Journey::Router#serve, L29) and returns a 404 with the same header when nothing matches.
Dispatch: The Last Hop Into Your Action
The matched route’s app is usually a constraint wrapper: Constraints#serve returns [404, {"X-Cascade" => "pass"}, []] when a constraints: block fails, otherwise calls the strategy (Constraints#serve, L57). The strategy is the Dispatcher (Dispatcher#serve, L40): it reads req.path_parameters, resolves req.controller_class — which constantizes "posts" into PostsController with a reload-safe cache (Request#controller_class, L175, controller_class_for, L181) — and builds a fresh ActionDispatch::Response via controller.make_response! (Metal.make_response!, L203).
Then the single most deceptively simple method in Rails runs. Metal#dispatch is five lines (Metal#dispatch, L319): set the request, set the response, process(name), commit the flash, and to_a — which serializes the response back into the Rack triple. AbstractController::Base#process resolves the action name and raises ActionNotFound if the method is not an action (Base#process, L158), then process_action is literally send_action, an alias for Ruby’s send (Base#process_action, L235). Everything you love about controllers — filters, rendering, streaming — is layered around those five lines, not inside them.
From Install to Your First Model
The quickstart in the repo’s README is four commands, and each one maps to machinery we just read: gem install rails installs the metagem; rails new myapp runs the application generator to scaffold the app; cd myapp and bin/rails server start the server via the server command in railties (server_command.rb); opening http://localhost:3000 shows the Rails bootscreen with your Rails and Ruby versions. From there, rails generate model produces your first model through the model generator in railties (model_generator.rb), and rails generate scaffold wires up the full controller-plus-views set (scaffold_generator.rb). The generated controller inherits exactly the dispatch path from the previous section — no hidden second router, no per-controller routing layer.
Honest Limits
- Everything cited here was verified at
maincommitafdc2eac(Rails 8.2.0.alpha);mainmoves daily, so line numbers will drift even when the code does not. - The default stack is config-dependent: API-only mode drops
MethodOverride,Cookies,Flash, and the CSP/PermissionsPolicy middlewares (build_stack, L58, L79), andHostAuthorizationexists only when hosts are set (L17). Do not assume any specific list without checking your ownrails middlewareoutput. - The static fast path only covers literal single-verb routes without constraints; dynamic paths always pay the simulator walk and parameter extraction (
static_exact_map, L89). - We deliberately left out parameter parsing inside
ActionDispatch::Request, view rendering, Active Record, and caching internals — each is its own tour. - The diagrams are hand-built graphs of the verified call path, not runtime traces; they show structure, not measured timing.
Enjoyed this post? Never miss out on future posts by following us