How Rails builds a page with content_for and yield
This article is about one small pair of tools: yield and content_for. They look trivial until you have to answer "in what order does this actually run" - and especially "what happens if I declare a section that nobody outputs".
Everything below was verified on ActionView 6.1; the experiment code is included.
The problem
A layout is a frame around the page. The problem is that the frame needs data only the concrete page knows: the tab title, social <meta> tags, its own CSS, breadcrumbs, buttons in the header.
The naive solution is instance variables:
<%# app/controllers/products_controller.rb %>
@page_title = "Nike sneakers"
<%# app/views/layouts/application.html.erb %>
<title><%= @page_title %></title>
It works for strings and falls apart on markup: if the title needs a <span> or a link, you start assembling HTML in the controller. content_for solves exactly that - it lets a template send a piece of markup upward into the layout.
Three words of vocabulary
<%# layouts/application.html.erb %>
<head>
<title><%= yield :title %></title>
<%= yield :head %>
</head>
<body class="<%= content_for?(:sidebar) ? 'two-column' : 'one-column' %>">
<main><%= yield %></main>
<%= yield :sidebar %>
</body>
<%# products/show.html.erb %>
<% content_for :title, "Nike sneakers" %>
<% content_for :head do %>
<meta property="og:image" content="<%= @product.image_url %>">
<% end %>
<h1><%= @product.name %></h1>
yieldwith no argument - the main page body;yield :name- a named slot;content_for :name- filling a slot (with a block or a string);content_for?(:name)- checking that the slot is non-empty, without outputting it.
The difference between reading via yield :name and content_for :name is one practical thing: yield works only inside templates, content_for also works inside helpers.
Assembly order: bottom-up
The main non-obvious part: the layout renders last. Not first, the way intuition suggests ("frame first, then content").
Here is the Rails code that does it - actionview/lib/action_view/renderer/template_renderer.rb:
def render_with_layout(view, template, path, locals)
layout = path && find_layout(path, locals.keys, [formats.first])
body = if layout
# yield(layout) here renders the main template.
# Its result is stored in the buffer under the key :layout ...
view.view_flow.set(:layout, yield(layout))
# ... and only then is the layout itself rendered
layout.render(view, locals) { |*name| view._layout_for(*name) }
else
yield
end
build_rendered_template(body, template)
end
And bare yield inside a layout is simply reading that same buffer (action_view/context.rb):
def _layout_for(name = nil)
name ||= :layout
view_flow.get(name).html_safe
end
So <%= yield %> in a layout does not "run" the template. The template has already finished; yield pulls a ready string out of a hash under the key :layout.
Experiment
We will build a mini stand on bare ActionView and record the real call order:
require "action_view"
require "action_view/testing/resolvers"
TEMPLATES = {
"layouts/app.html.erb" => <<~ERB,
<% $log << "layout: start" %>
HEAD[<%= yield :head %>]
BODY[<%= yield %>]
<% $log << "layout: end" %>
ERB
"page.html.erb" => <<~ERB,
<% $log << "page: start" %>
<% content_for :head do %><% $log << "page: content_for(:head) block" %>meta<% end %>
<% content_for :nowhere do %><% $log << "page: content_for(:nowhere) block" %>wasted<% end %>
<%= render "widget" %>
<% $log << "page: end" %>
ERB
"_widget.html.erb" => <<~ERB,
<% $log << "partial: runs" %>
<% content_for :head do %><% $log << "partial: content_for(:head) block" %>+css<% end %>
<widget>
ERB
}
$log = []
lookup = ActionView::LookupContext.new([ActionView::FixtureResolver.new(TEMPLATES)])
view = ActionView::Base.with_empty_template_cache.new(lookup, {}, nil)
puts view.render(template: "page", layout: "layouts/app")
puts $log
Output:
1. page: start
2. page: content_for(:head) block
3. page: content_for(:nowhere) block
4. partial: runs
5. partial: content_for(:head) block
6. page: end
7. layout: start
8. layout: end
HEAD[meta+css]
BODY[
<widget>
]
Notice: HEAD stands above BODY in the finished HTML, even though it ran later. Source order and execution order are different things.
You also see that content_for from a partial quietly reached the layout and was appended to what the page put there: meta+css. By default content_for concatenates rather than replaces.
sequenceDiagram
participant C as Controller
participant TR as TemplateRenderer
participant V as page.html.erb
participant P as _widget.html.erb
participant F as view_flow (Hash)
participant L as layouts/app.html.erb
C->>TR: render :show
TR->>V: render (template first!)
V->>F: content_for :head -> "meta"
V->>F: content_for :nowhere -> "wasted"
V->>P: render "widget"
P->>F: content_for :head -> += "+css"
P-->>V: widget HTML
V-->>TR: page body
TR->>F: set(:layout, page body)
TR->>L: render (layout last)
L->>F: yield :head -> get(:head)
L->>F: yield -> get(:layout)
L-->>C: finished HTML
Where the content lives
All of this is one ActionView::OutputFlow object that lives for exactly one request (action_view/flows.rb):
class OutputFlow
def initialize
@content = Hash.new { |h, k| h[k] = ActiveSupport::SafeBuffer.new }
end
def get(key)
@content[key]
end
def set(key, value)
@content[key] = ActiveSupport::SafeBuffer.new(value)
end
def append(key, value)
@content[key] << value
end
end
A hash of strings, and nothing more. The page body is stored there too, under the reserved key :layout, next to your :head and :sidebar.
content_for itself is also simple:
def content_for(name, content = nil, options = {}, &block)
if content || block_given?
if block_given?
options = content if content
content = capture(&block) # <-- the block runs HERE, immediately
end
if content
options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content)
end
nil
else
@view_flow.get(name).presence
end
end
The key line is capture(&block). capture swaps the output buffer, runs the block fully, and returns the accumulated string. There is no laziness: the block executes when it is encountered, whether anyone will need it or not.
What if there is content_for but no yield?
That was the original question. The answer: the block runs, HTML is built, the string lands in the hash and sits there until the end of the request, then GC collects it.
In the experiment above, the :nowhere section was never output. Check the buffer state after render:
p view.view_flow.content.keys
# => [:head, :nowhere, :layout]
p view.view_flow.content[:nowhere].to_s
# => "wasted"
The log line 3. page: content_for(:nowhere) block is the proof: the work was done. Rails does not analyze the layout ahead of time and does not know which slots anyone is waiting for.
How much does it cost
Let's measure. 200 "garbage" content_for calls inside a loop on one page, 300 renders:
Benchmark.bm(22) do |x|
x.report("without content_for") { N.times { run(a) } }
x.report("content_for to nowhere") { N.times { run(b) } }
end
user system total real
without content_for 0.079012 0.004745 0.083757 ( 0.083810)
content_for to nowhere 0.179607 0.007918 0.187525 ( 0.187599)
allocated objects: without=1852 with garbage content_for=3499
The render became 2.2x slower, and almost twice as many objects were allocated. But recalculated per call: about 1.7 microseconds and ~8 objects.
Practical takeaway from these numbers:
- One or two "dead"
content_forcalls on a page are noise. Against a database query they are invisible. There is nothing to optimize here. content_forinside a loop or inside a collection partial is real money. A thousand table rows, each with its owncontent_for, means milliseconds and GC garbage for no good reason.- If the block hits the database or a helper with logic, the cost is no longer in microseconds.
capturewill run everything honestly, including@product.reviews.count.
The real problem is not performance
It is silence. Rails gives no warning if a name does not match:
<%# layout %>
<%= yield :sidebar %>
<%# view %>
<% content_for :side_bar do %>...<% end %>
Tests stay green, the page renders, the sidebar is empty. Debugging this means eyeballing two names in different files.
A couple of practices that help:
<%# 1. Explicitly show that the slot is optional - and change the layout at the same time %>
<body class="<%= content_for?(:sidebar) ? 'has-sidebar' : '' %>">
<%# 2. A default value %>
<title><%= content_for?(:title) ? yield(:title) : "Store" %></title>
And if you want guarantees - put slot names into constants or helpers (page_title(...) instead of content_for :title) so a typo is caught at the Ruby level, not by eye.
Side effect: accumulation
Because content_for appends by default, a partial in a collection will write as many times as there are items:
T = {
"layouts/app.html.erb" => "SIDEBAR[<%= yield :sidebar %>]\nBODY[<%= yield %>]",
"page.html.erb" => "<%= render partial: 'item', collection: [1,2,3] %>",
"_item.html.erb" => "<% content_for :sidebar do %>(<%= item %>)<% end %>[<%= item %>]"
}
SIDEBAR[(1)(2)(3)]
BODY[[1][2][3]]
Sometimes that is exactly what you want (collect every modal on the page). Sometimes it is a triple <script> in <head>. The fix is content_for :sidebar, flush: true do, which calls set instead of append.
A separate trap: content_for does not survive fragment caching. Rails says so honestly in the source:
WARNING:
content_foris ignored in caches. So you shouldn't use it for elements that will be fragment cached.
On a cache hit, the cache do ... end block does not run - a ready string is returned. That means content_for inside it also does not run, and the slot ends up empty. On a cold cache everything works; on a warm cache it breaks, which makes the bug especially pleasant.
When the order flips: provide and streaming
content_for has a sibling - provide. In normal mode the difference is almost invisible, but with render stream: true it is fundamental.
When streaming, Rails wants to send <head> to the browser as early as possible, but <head> lives in the layout, and its content comes from the template. The "template, then layout" order does not work here. The solution is fibers (renderer/streaming_template_renderer.rb):
fiber = Fiber.new { layout.render(view, locals, output, &yielder) }
view.view_flow = StreamingFlow.new(view, fiber)
fiber.resume # start the layout FIRST
if fiber.alive? # layout paused on yield :title
content = template.render(view, locals, &yielder)
view.view_flow.set(:layout, content)
fiber.resume while fiber.alive?
end
StreamingFlow#get with a missing key does Fiber.yield - it freezes the layout and hands control to the template. And provide on write does @fiber.resume and unfreezes the layout again:
def append!(key, value)
super
@fiber.resume if @waiting_for == key
end
sequenceDiagram
participant L as layout (in Fiber)
participant F as StreamingFlow
participant V as view
participant B as browser
L->>B: opened html and head
L->>F: yield :title
F-->>L: key missing -> Fiber.yield
F->>V: render template
V->>F: provide :title, "Sneakers"
F->>L: fiber.resume
L->>B: title and close head
Note over V: template finishes rendering
V->>F: set(:layout, body)
L->>B: body with page content
The semantic difference: content_for says "I might append more", so under streaming the layout waits until the end of the template. provide says "the slot is done" - and the layout continues immediately. That is why streaming pages use provide for <title> and <head>, and leave content_for for slots assembled from multiple places.
Two families of template engines
Here it becomes clear that Rails did not choose the only possible model. Every engine with "blocks" splits into two camps.
flowchart TB
subgraph PUSH["Push / buffer: child pushes"]
direction TB
C1["child runs fully"] --> C2["each content_for<br/>writes to a shared buffer"]
C2 --> C3["layout reads the buffer"]
C3 --> C4["extra section ran,<br/>result discarded"]
end
subgraph PULL["Pull / inheritance: parent pulls"]
direction TB
P1["parent: block head"] --> P2["looks for an override<br/>in the child template"]
P2 --> P3["calls only the blocks<br/>it encountered"]
P3 --> P4["child's unused block<br/>does NOT run"]
end
Pull: template inheritance
Jinja2 / Django (Python), Twig (PHP), Smarty 3+ (PHP), Go html/template, handlebars-layouts (Node).
The child declares {% extends %}, the parent runs the show. Blocks compile into separate functions, and the parent calls the ones it reaches.
I checked on Jinja2 - a nowhere block that is absent from the base template does not run at all:
'base.html': 'HEAD[{% block head %}default{% endblock %}] BODY[{% block content %}{% endblock %}]',
'page.html': '''{% extends "base.html" %}
{% block head %}{{ log("head block runs") }}meta{% endblock %}
{% block content %}{{ log("content block runs") }}page body{% endblock %}
{% block nowhere %}{{ log("NOWHERE block runs") }}wasted{% endblock %}'''
RUN: head block runs
RUN: content block runs
HEAD[meta] BODY[page body]
NOWHERE never appeared. And here execution order matches HTML order: head before content, because that is how it stands in the parent. In Rails it would be the opposite.
The same in Go, where named templates in a set play the role of blocks:
base := `HEAD[{{block "head" .}}default{{end}}] BODY[{{template "content" .}}]`
child := `{{define "content"}}...{{end}}
{{define "head"}}...{{end}}
{{define "nowhere"}}{{log "NOWHERE"}}wasted{{end}}`
RUN: layout start
RUN: head block
RUN: content block
RUN: layout end
nowhere is parsed, sits in the template set, and is simply never called. The cost is a few bytes of memory for the parsed tree, zero CPU time.
The same result on handlebars-layouts: {{#content "nowhere"}} does not run, because blocks are stored as deferred functions and are only called from the parent's {{#block}}.
Smarty stands apart and deserves a separate mention, because it can do both approaches. Its {extends} / {block} resolve at compile time - a child block with no pair in the parent is physically cut out by the compiler and never reaches compiled PHP. But Smarty also has a second mechanism, {capture name="foo"}...{/capture} read via {$smarty.capture.foo}, and that is exactly the Rails model: run it and put it in an array.
Push: buffer and reverse order
Rails ActionView, Laravel Blade, express-ejs-layouts (Node).
First the page runs fully, filling a shared buffer along the way, then the wrapper renders and reads from the buffer.
Blade is almost a copy of Rails: @section writes into the $sections array, @yield('name') reads. @extends does not start the layout immediately; it defers its render until the end of the child template. A section without @yield likewise runs for nothing. The closest analogue of concatenating content_for is @push / @stack.
In Node there is no such mechanism in stock EJS or Handlebars: plain EJS only has include(), plain Handlebars only has partials. The layout model comes from wrappers. The express-ejs-layouts implementation is the most straightforward of all - contentFor simply inserts a text marker into the output:
var contentPattern = '&&<>&&';
function contentFor(contentName) {
return contentPattern + contentName + contentPattern;
}
function parseContents(locals) {
var str = locals.body,
regex = new RegExp('\r?\n?' + contentPattern + '.+?' + contentPattern + '\r?\n?', 'g'),
split = str.split(regex),
matches = str.match(regex);
locals.body = split[0];
// ... content between markers is laid out into locals[name]
}
So the page renders into one full string, then is cut with a regex on delimiters. A section nobody will output does not just run for nothing here - it also takes part in string parsing.
Summary
| Engine | Mechanics | Order | Unused block |
|---|---|---|---|
| Rails ActionView | view_flow hash |
bottom-up | runs, result discarded |
| Laravel Blade | $sections array |
bottom-up | runs, result discarded |
| express-ejs-layouts | markers in a string + split |
bottom-up | runs and is parsed by regex |
Smarty {capture} |
$smarty.capture array |
bottom-up | runs, result discarded |
| Jinja2 / Django | block inheritance | top-down | does not run |
| Twig | blocks compile into methods | top-down | does not run |
Smarty {extends} |
merge at compile time | top-down | cut out by the compiler |
Go html/template |
named templates in a set | top-down | does not run |
| handlebars-layouts | blocks as deferred functions | top-down | does not run |
What Rails pays and what it buys
The pull model is more efficient and more predictable in execution order. But it has a hard limit: only the child template itself can fill a slot. In Jinja2, a partial included via {% include %} three levels deep cannot append a <script> into the parent's <head> - blocks belong to the child-parent relationship, not to the whole render tree.
In Rails, anyone can, from any depth. A component in a partial in a partial in a collection adds its CSS to <head>, and it works because there is one buffer for the whole request. That is what makes things like javascript_include_tag from a nested component, or collecting modals from the whole page into one block before </body>, possible.
The extra microseconds on a "dead" content_for are the price of exactly that freedom. Rails cannot know ahead of time who will want to put something where, so it runs everything and sorts it out afterward.
Cheat sheet
- The layout renders after the template.
yielddoes not run the template; it reads a ready string fromview_flow[:layout]. content_forruns the block immediately viacapture. There is no laziness.- A section without a matching
yieldis work that ran and was thrown away. One call is ~1.7 µs: not critical. In a loop or with a database query inside - already noticeable. - Rails stays silent on a slot name typo. That is more dangerous than lost microseconds.
content_forappends by default. Need replacement -flush: true.content_fordoes not work inside a fragment cache on a cache hit.- To read a slot in a helper you can only use
content_for :name;yield :nameis unavailable there. - For streaming (
render stream: true) useprovideinstead ofcontent_for.
Links
- Layouts and Rendering in Rails - Structuring Layouts
- Turbo Frames from Scratch and Stimulus from Scratch - what happens to this HTML next, already in the browser