How Rails builds a page with content_for and yield

content_for and yield in Rails

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>
  • yield with 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_for calls on a page are noise. Against a database query they are invisible. There is nothing to optimize here.
  • content_for inside a loop or inside a collection partial is real money. A thousand table rows, each with its own content_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. capture will 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_for is 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. yield does not run the template; it reads a ready string from view_flow[:layout].
  • content_for runs the block immediately via capture. There is no laziness.
  • A section without a matching yield is 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_for appends by default. Need replacement - flush: true.
  • content_for does 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 :name is unavailable there.
  • For streaming (render stream: true) use provide instead of content_for.