<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thoughtbot="https://thoughtbot.com/feeds/" xmlns:feedpress="https://feed.press/xmlns" xmlns:media="http://search.yahoo.com/mrss/" xmlns:podcast="https://podcastindex.org/namespace/1.0">
  <feedpress:locale>en</feedpress:locale>
  <link rel="hub" href="https://feedpress.superfeedr.com/"/>
  <title>Giant Robots Smashing Into Other Giant Robots</title>
  <subtitle>Written by thoughtbot, your expert partner for design and development.
</subtitle>
  <id>https://robots.thoughtbot.com/</id>
  <link href="https://thoughtbot.com/blog"/>
  <link href="https://feed.thoughtbot.com/" rel="self"/>
  <updated>2026-08-11T00:00:00+00:00</updated>
  <author>
    <name>thoughtbot</name>
  </author>
  <entry>
    <title>Modeling State Transitions in Postgres</title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24077/17412215/modeling-state-transitions-in-postgres"/>
    <author>
      <name>Thiago Araújo Silva</name>
    </author>
    <id>https://thoughtbot.com/blog/modeling-state-transitions-in-postgres</id>
    <published>2026-08-11T00:00:00+00:00</published>
    <updated>2026-08-10T16:43:34Z</updated>
    <content type="html"><![CDATA[<p>On most projects I’ve consulted on, status starts as a column. It
works, until someone asks “who was denied last Tuesday?” and the
schema can’t answer. At that point, you can’t retrofit history you
never recorded.</p>

<p>There’s a better way: model each status change as its own row from
the start. You get full history and the current state in one design,
without sacrificing read performance. Here’s how.</p>

<p>Say we have a <code>users</code> table with <code>name</code> and <code>status</code> columns:</p>

<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>status</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Paul Winston</td>
<td><code>pending</code></td>
</tr>
<tr>
<td>2</td>
<td>Bob Marley</td>
<td><code>ready_for_review</code></td>
</tr>
<tr>
<td>3</td>
<td>Carlos Lagrande</td>
<td><code>denied</code></td>
</tr>
</tbody>
</table>

<p>This design has an obvious limitation: if we change a user’s status,
we can’t be sure of:</p>

<ul>
<li>
<em>What</em> the previous status was;</li>
<li>
<em>When</em> the status changed.</li>
</ul>

<p>Now imagine your stakeholders start asking questions like:</p>

<ul>
<li>Who was denied last Tuesday?</li>
<li>How long did users stay in each status?</li>
<li>Which users were denied and later re-approved?</li>
</ul>

<p>These questions require history the current design has already thrown
away. A single modeling change answers all of them efficiently,
without sacrificing the one thing the current design does well:
getting the current status.</p>
<h2 id="the-user-statuses-table">
  
    The user statuses table
  
</h2>

<p>Instead of updating a <code>status</code> column on <code>users</code>, we create a separate
table where each status change is a new row:</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">user_statuses</span> <span class="p">(</span>
  <span class="n">id</span> <span class="nb">BIGINT</span> <span class="k">GENERATED</span> <span class="n">ALWAYS</span> <span class="k">AS</span> <span class="k">IDENTITY</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
  <span class="n">user_id</span> <span class="nb">BIGINT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">REFERENCES</span> <span class="n">users</span><span class="p">(</span><span class="n">id</span><span class="p">),</span>
  <span class="n">status</span> <span class="nb">VARCHAR</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">created_at</span> <span class="n">TIMESTAMPTZ</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="n">now</span><span class="p">()</span>
<span class="p">);</span>
</code></pre></div>
<p>When a user’s status changes, we insert a new record. We never
update or delete existing ones.</p>

<table>
<thead>
<tr>
<th>id</th>
<th>user_id</th>
<th>status</th>
<th>created_at</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>1</td>
<td><code>pending</code></td>
<td>2026-07-10 09:00:00</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
<td><code>ready_for_review</code></td>
<td>2026-07-12 14:30:00</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td><code>approved</code></td>
<td>2026-07-15 11:00:00</td>
</tr>
<tr>
<td>4</td>
<td>2</td>
<td><code>pending</code></td>
<td>2026-07-11 10:00:00</td>
</tr>
<tr>
<td>5</td>
<td>2</td>
<td><code>denied</code></td>
<td>2026-07-13 16:45:00</td>
</tr>
</tbody>
</table>

<aside class="info">
  <p>Notice the table is called <code>user_statuses</code>, not
  <code>user_status_history</code> or
  <code>user_status_events</code>. Calling it “history” implies that
  the real status lives somewhere else and this table is just a log.
  Calling it “events” suggests an event-driven architecture where
  these records trigger downstream reactions. Neither is what’s
  happening here. This table <em>is</em> the source of truth for what
  a user’s status is, right now and at any point in the past.</p>
</aside>

<p>It’s worth stepping back and asking: what <em>is</em> a user’s current
status? With this model, the answer becomes a definition:</p>

<blockquote>
<p>A user’s current status is their most recently recorded status.</p>
</blockquote>

<p>Not a value we store and keep in sync, but a query we run against the
timeline. Adding a <code>status</code> column on <code>users</code> to speed up reads would
mean caching a fact already derivable from <code>user_statuses</code>, a
violation of <a href="https://en.wikipedia.org/wiki/Third_normal_form">Third Normal Form</a>
that creates two sources of truth that can drift apart.</p>

<p>If an attribute has a lifecycle, discrete transitions like <code>pending</code>
to <code>approved</code>, consider tracking its changes by default, as
retrofitting history after the fact means backfilling data you never
recorded. The rest of this article shows that it’s possible to query it
efficiently.</p>
<h2 id="querying-the-current-status">
  
    Querying the current status
  
</h2>

<p>There is a tradeoff, of course. Normalized data like this is harder to
query. “Give me each user’s current status” used to be a simple column
read. Now it requires finding the most recent <code>user_statuses</code> row per
user. But harder to query does not mean slow. With the right approach
and proper indexing, it’s possible to keep the data normalized and
still have good query performance.</p>

<p>There are several ways to do this in SQL, and they differ in
clarity, composability, and performance.</p>

<aside class="warn">
  <p>You might be tempted to add a <code>current</code> boolean column
  to make querying easy. But maintaining it requires unsetting all
  existing rows for that user and setting the new one on every status
  change. If keeping a field consistent requires that much work, the
  field probably doesn’t belong in the model.</p>
</aside>

<p>All of the approaches below benefit from a composite index that lets
Postgres locate a user’s most recent status without scanning the
entire table:</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_user_statuses_user_id_created_at</span>
  <span class="k">ON</span> <span class="n">user_statuses</span> <span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span><span class="p">)</span>
  <span class="n">INCLUDE</span> <span class="p">(</span><span class="n">status</span><span class="p">);</span>
</code></pre></div>
<p>The B-tree is organized by <code>(user_id, created_at DESC, id DESC)</code> for
fast lookups. <a href="https://use-the-index-luke.com/blog/2019-04/include-columns-in-btree-indexes"><code>INCLUDE (status)</code></a> stores <code>status</code> in
the index leaf pages as a non-key column, so Postgres can answer
queries without fetching the row from the heap. This turns an Index
Scan into an <a href="https://use-the-index-luke.com/sql/clustering/index-only-scan-covering-index">Index Only Scan</a>.</p>

<aside class="info">
  <p>All the approaches below sort by <code>created_at DESC, id
  DESC</code>, not just <code>created_at DESC</code>. Since
  <code>created_at</code> is not unique, sorting by it alone may produce
  a non-deterministic result. Adding <code>id DESC</code> as a
  tiebreaker guarantees a stable result. I wrote about this in <a href="https://thoughtbot.com/blog/do-you-really-know-how-to-order-by">Do
  you really know how to ORDER BY?</a></p>
</aside>
<h3 id="correlated-subquery">
  
    Correlated subquery
  
</h3>

<p>The most straightforward approach. A subquery in the <code>SELECT</code> returns
a single value per user. Simple and portable across databases.</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span>
  <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
  <span class="n">users</span><span class="p">.</span><span class="n">name</span><span class="p">,</span>
  <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">status</span>
    <span class="k">FROM</span> <span class="n">user_statuses</span>
    <span class="k">WHERE</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
    <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span>
    <span class="k">LIMIT</span> <span class="mi">1</span>
  <span class="p">)</span> <span class="k">AS</span> <span class="n">current_status</span>
<span class="k">FROM</span> <span class="n">users</span><span class="p">;</span>
</code></pre></div>
<p>If you only need the status, this works well. But each additional
column from the latest status (like <code>created_at</code>) requires another
correlated subquery, which doubles the index lookups.</p>

<aside class="warn">
  <p>You might see <a href="https://gist.github.com/thiagoa/9bcec7dba3676522c785d580d4f25af1">variations
  that use <code>MAX(id)</code></a> instead of <code>ORDER BY ...
  LIMIT 1</code>. This assumes the highest <code>id</code> is
  always the latest status, which <a href="https://thoughtbot.com/blog/do-you-really-know-how-to-order-by">breaks
  during backfills or data corrections</a>. <code>ORDER BY
  created_at DESC, id DESC</code> expresses the business rule
  directly.</p>
</aside>
<h3 id="window-function">
  
    Window function
  
</h3>

<p>Numbers every row per user with <code>ROW_NUMBER()</code>, then filters to the
first. This solves the multiple-column problem: you can select
anything from the latest row. With the right index (like ours),
Postgres can stop early per partition and avoid scanning extraneous
rows.</p>

<p>The main downside is ergonomic: the query requires wrapping
in a subquery just to filter on the computed row number. This
doesn’t usually play well with ORM pagination, as the ORM won’t
know the query needs to be wrapped in yet another subquery for
<code>COUNT(*)</code> or <code>LIMIT</code>/<code>OFFSET</code> to work correctly.</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">latest</span><span class="p">.</span><span class="n">status</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="p">(</span>
  <span class="k">SELECT</span>
    <span class="n">user_id</span><span class="p">,</span>
    <span class="n">status</span><span class="p">,</span>
    <span class="n">ROW_NUMBER</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span>
      <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span>
      <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span>
    <span class="p">)</span> <span class="k">AS</span> <span class="n">row_number</span>
  <span class="k">FROM</span> <span class="n">user_statuses</span>
<span class="p">)</span> <span class="n">latest</span> <span class="k">ON</span> <span class="n">latest</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span> <span class="k">AND</span> <span class="n">latest</span><span class="p">.</span><span class="n">row_number</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div><h3 id="distinct-on">
  
    DISTINCT ON
  
</h3>

<p>Keeps the first row per group based on the <code>ORDER BY</code>. Concise and
Postgres-native, but it sorts all status rows for the involved users
before deduplicating.</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="k">ON</span> <span class="p">(</span><span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span><span class="p">)</span>
  <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">status</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">user_statuses</span> <span class="k">ON</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span><span class="p">,</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">id</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div>
<aside class="info">
  <p><code>DISTINCT ON</code> is
  <a href="https://gist.github.com/thiagoa/9566bba15e1e4426b4a0ba97c75963f3">much
  faster with <code>INNER JOIN</code> than <code>LEFT JOIN</code></a>. If
  your data model enforces that every user has at least one status
  row, for example by inserting an initial status on user creation,
  <code>INNER JOIN</code> is safe and makes <code>DISTINCT ON</code>
  viable.</p>
</aside>
<h3 id="lateral-join">
  
    Lateral join
  
</h3>

<p>Runs a subquery per row in the outer query that can reference that
row’s columns. With an index, each lookup reads exactly one row.
Unlike <code>DISTINCT ON</code>, it reads exactly one status row per user.</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">latest</span><span class="p">.</span><span class="n">status</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">LATERAL</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="n">status</span>
  <span class="k">FROM</span> <span class="n">user_statuses</span>
  <span class="k">WHERE</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
  <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span>
  <span class="k">LIMIT</span> <span class="mi">1</span>
<span class="p">)</span> <span class="n">latest</span> <span class="k">ON</span> <span class="k">true</span><span class="p">;</span>
</code></pre></div>
<p>For each user, Postgres dips into <code>user_statuses</code>, grabs the most
recent row via the index, and moves on.</p>

<p>Unlike the window function approach, the outer query stays flat,
so ORMs can add <code>LIMIT</code>/<code>OFFSET</code> or wrap it with <code>COUNT(*)</code>
without issues.</p>
<h2 id="benchmarking-the-approaches">
  
    Benchmarking the approaches
  
</h2>

<p>I ran <code>EXPLAIN ANALYZE</code> on all four approaches.</p>

<details>
<summary>Benchmark setup</summary>
<pre>
-- Postgres 17, 100,000 users, 5,000,000 status changes (roughly 50
-- per user), with a covering index on (user_id, created_at DESC,
-- id DESC) INCLUDE (status).

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name VARCHAR NOT NULL
);

CREATE TABLE user_statuses (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  status VARCHAR NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO users (name)
SELECT 'User ' || n
FROM generate_series(1, 100000) AS n;

INSERT INTO user_statuses (user_id, status, created_at)
SELECT
  (random() * 99999)::int + 1,
  (ARRAY['pending', 'ready_for_review',
         'approved', 'denied', 'onboarding']
  )[floor(random() * 5 + 1)::int],
  now() - (random() * interval '365 days')
FROM generate_series(1, 5000000);

CREATE INDEX idx_user_statuses_user_id_created_at
  ON user_statuses (user_id, created_at DESC, id DESC)
  INCLUDE (status);
</pre>
</details>
<h3 id="with-a-single-user">
  
    With a single user
  
</h3>

<table>
<thead>
<tr>
<th>Approach</th>
<th>Execution Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Correlated subquery</td>
<td>0.015 ms</td>
</tr>
<tr>
<td>Window function</td>
<td>0.048 ms</td>
</tr>
<tr>
<td><code>DISTINCT ON</code></td>
<td>0.4 ms</td>
</tr>
<tr>
<td>Lateral join</td>
<td>0.012 ms</td>
</tr>
</tbody>
</table>

<p>For a single user, all approaches are sub-millisecond. The differences
are negligible at this scale.</p>

<details>
<summary>Queries and plans (single user)</summary>
<pre>
---------- CORRELATED SUBQUERY ----------

EXPLAIN ANALYZE
SELECT
  users.id,
  users.name,
  (
    SELECT status
    FROM user_statuses
    WHERE user_statuses.user_id = users.id
    ORDER BY created_at DESC, id DESC
    LIMIT 1
  ) AS current_status
FROM users
WHERE users.id = 42;

-- Plan: index-only scan with LIMIT 1

Index Scan using users_pkey on users  (rows=1)
  SubPlan 1
    -&gt;  Limit  (rows=1 loops=1)
          -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                on user_statuses  (rows=1 loops=1)

---------- WINDOW FUNCTION ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN (
  SELECT
    user_id, status,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY created_at DESC, id DESC
    ) AS row_number
  FROM user_statuses
) latest ON latest.user_id = users.id AND latest.row_number = 1
WHERE users.id = 42;

-- Plan: index-only scan over this user's rows, stops at row 1

Nested Loop Left Join  (rows=1)
  -&gt;  Index Scan using users_pkey on users  (rows=1)
  -&gt;  Subquery Scan on latest  (rows=1)
        Filter: (latest.row_number = 1)
        -&gt;  WindowAgg  (rows=1)
              Run Condition: (row_number() &lt;= 1)
              -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                    on user_statuses  (rows=2)

---------- DISTINCT ON ----------

EXPLAIN ANALYZE
SELECT DISTINCT ON (user_statuses.user_id)
  users.id, users.name, user_statuses.status
FROM users
LEFT JOIN user_statuses ON user_statuses.user_id = users.id
WHERE users.id = 42
ORDER BY user_statuses.user_id,
         user_statuses.created_at DESC,
         user_statuses.id DESC;

-- Plan: sorts all ~40 status rows for this user, then deduplicates

Unique  (rows=1)
  -&gt;  Sort  (rows=40)
        Sort Method: quicksort  Memory: 27kB
        -&gt;  Nested Loop Left Join  (rows=40)
              -&gt;  Index Scan using users_pkey on users  (rows=1)
              -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                    on user_statuses  (rows=40)

---------- LATERAL JOIN ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN LATERAL (
  SELECT status
  FROM user_statuses
  WHERE user_statuses.user_id = users.id
  ORDER BY created_at DESC, id DESC
  LIMIT 1
) latest ON true
WHERE users.id = 42;

-- Plan: index-only scan with LIMIT 1, single pass

Nested Loop Left Join  (rows=1)
  -&gt;  Index Scan using users_pkey on users  (rows=1)
  -&gt;  Limit  (rows=1 loops=1)
        -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
              on user_statuses  (rows=1 loops=1)
</pre>
</details>
<h3 id="with-a-page-of-15-users">
  
    With a page of 15 users
  
</h3>

<table>
<thead>
<tr>
<th>Approach</th>
<th>Execution Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Correlated subquery</td>
<td>0.2 ms</td>
</tr>
<tr>
<td>Window function</td>
<td>0.7 ms</td>
</tr>
<tr>
<td><code>DISTINCT ON</code></td>
<td>1,903 ms</td>
</tr>
<tr>
<td>Lateral join</td>
<td>0.07 ms</td>
</tr>
</tbody>
</table>

<p>The correlated subquery, window function, and lateral join are all
sub-millisecond. <code>DISTINCT ON</code> is catastrophically slower: Postgres
can’t push the <code>LIMIT</code> through the <code>Unique</code> node, so it hash-joins
all 5 million status rows, sorts them on disk, and only then
returns 15.</p>

<aside class="info">
  <p><code>DISTINCT ON</code> can be made faster at page size by
  <a href="https://gist.github.com/thiagoa/96227d846b56aa606c3f58a865edfb03">pre-filtering
  the users with a subquery</a>, but the workaround is awkward to
  express through an ORM.</p>
</aside>

<details>
<summary>Queries and plans (page of 15)</summary>
<pre>
---------- CORRELATED SUBQUERY ----------

EXPLAIN ANALYZE
SELECT
  users.id,
  users.name,
  (
    SELECT status
    FROM user_statuses
    WHERE user_statuses.user_id = users.id
    ORDER BY created_at DESC, id DESC
    LIMIT 1
  ) AS current_status
FROM users
ORDER BY id
LIMIT 15;

-- Plan: index-only scan with LIMIT 1, loops=15

Limit  (rows=15)
  -&gt;  Index Scan using users_pkey on users  (rows=15)
        SubPlan 1
          -&gt;  Limit  (rows=1 loops=15)
                -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                      on user_statuses  (rows=1 loops=15)

---------- WINDOW FUNCTION ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN (
  SELECT
    user_id, status,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY created_at DESC, id DESC
    ) AS row_number
  FROM user_statuses
) latest ON latest.user_id = users.id AND latest.row_number = 1
ORDER BY users.id
LIMIT 15;

-- Plan: index-only scan, stops after first row per partition

Limit  (rows=15)
  -&gt;  Merge Left Join  (rows=15)
        -&gt;  Index Scan using users_pkey on users  (rows=15)
        -&gt;  Materialize  (rows=15)
              -&gt;  Subquery Scan on latest  (rows=15)
                    Filter: (latest.row_number = 1)
                    -&gt;  WindowAgg  (rows=15)
                          Run Condition: (row_number() &lt;= 1)
                          -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                                on user_statuses  (rows=681)

---------- DISTINCT ON ----------

EXPLAIN ANALYZE
SELECT DISTINCT ON (user_statuses.user_id)
  users.id, users.name, user_statuses.status
FROM users
LEFT JOIN user_statuses ON user_statuses.user_id = users.id
ORDER BY user_statuses.user_id,
         user_statuses.created_at DESC,
         user_statuses.id DESC
LIMIT 15;

-- Plan: hash joins all 5 million rows, sorts on disk, then returns 15

Limit  (rows=15)
  -&gt;  Unique  (rows=15)
        -&gt;  Sort  (rows=5000000)
              Sort Method: external merge  Disk: 330696kB
              -&gt;  Hash Right Join  (rows=5000000)
                    -&gt;  Seq Scan on user_statuses  (rows=5000000)
                    -&gt;  Hash
                          -&gt;  Seq Scan on users  (rows=100000)

---------- LATERAL JOIN ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN LATERAL (
  SELECT status
  FROM user_statuses
  WHERE user_statuses.user_id = users.id
  ORDER BY created_at DESC, id DESC
  LIMIT 1
) latest ON true
ORDER BY users.id
LIMIT 15;

-- Plan: index-only scan with LIMIT 1, loops=15

Limit  (rows=15)
  -&gt;  Nested Loop Left Join  (rows=15)
        -&gt;  Index Scan using users_pkey on users  (rows=15)
        -&gt;  Limit  (rows=1 loops=15)
              -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                    on user_statuses  (rows=1 loops=15)
</pre>
</details>

<p>These numbers assume the first page. With a high <code>OFFSET</code>, all
approaches degrade because Postgres processes every skipped row
before returning results. At <code>OFFSET 99000</code>, even the lateral
join takes around 157 ms to return 15 rows.
<a href="https://use-the-index-luke.com/no-offset">Cursor pagination</a> avoids this entirely by filtering
with <code>WHERE id &gt; :last_seen_id</code> instead of skipping rows.</p>
<h3 id="with-all-users-100000">
  
    With all users (100,000)
  
</h3>

<table>
<thead>
<tr>
<th>Approach</th>
<th>Execution Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Correlated subquery</td>
<td>223 ms</td>
</tr>
<tr>
<td>Window function</td>
<td>459 ms</td>
</tr>
<tr>
<td><code>DISTINCT ON</code></td>
<td>2,823 ms</td>
</tr>
<tr>
<td>Lateral join</td>
<td>174 ms</td>
</tr>
</tbody>
</table>

<p><code>DISTINCT ON</code> is the slowest by far: it hash-joins all 5 million
rows, then sorts them on disk. The window function walks the index
in order and stops early per partition. The correlated subquery
and lateral join are neck and
neck: both do an index-only scan with <code>LIMIT 1</code>, executed once
per user: 100,000 random reads into the index. The correlated
subquery would fall behind if it needed more columns, since each
additional column requires another subplan.</p>

<details>
<summary>Queries and plans (100,000 users)</summary>
<pre>
---------- CORRELATED SUBQUERY ----------

EXPLAIN ANALYZE
SELECT
  users.id,
  users.name,
  (
    SELECT status
    FROM user_statuses
    WHERE user_statuses.user_id = users.id
    ORDER BY created_at DESC, id DESC
    LIMIT 1
  ) AS current_status
FROM users;

-- Plan: index-only scan with LIMIT 1, executed once per user (loops=100000)

Seq Scan on users  (rows=100000)
  SubPlan 1
    -&gt;  Limit  (rows=1 loops=100000)
          -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                on user_statuses  (rows=1 loops=100000)

---------- WINDOW FUNCTION ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN (
  SELECT
    user_id, status,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY created_at DESC, id DESC
    ) AS row_number
  FROM user_statuses
) latest ON latest.user_id = users.id AND latest.row_number = 1;

-- Plan: walks all 5 million rows in index order, stops at first per partition

Merge Right Join  (rows=100000)
  -&gt;  Subquery Scan on latest  (rows=100000)
        Filter: (latest.row_number = 1)
        -&gt;  WindowAgg  (rows=100000)
              Run Condition: (row_number() &lt;= 1)
              -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
                    on user_statuses  (rows=5000000)
  -&gt;  Index Scan using users_pkey on users  (rows=100000)

---------- DISTINCT ON ----------

EXPLAIN ANALYZE
SELECT DISTINCT ON (user_statuses.user_id)
  users.id, users.name, user_statuses.status
FROM users
LEFT JOIN user_statuses ON user_statuses.user_id = users.id
ORDER BY user_statuses.user_id,
         user_statuses.created_at DESC,
         user_statuses.id DESC;

-- Plan: hash joins all 5 million rows, then sorts on disk

Unique  (rows=100000)
  -&gt;  Sort  (rows=5000000)
        Sort Method: external merge  Disk: 330696kB
        -&gt;  Hash Right Join  (rows=5000000)
              -&gt;  Seq Scan on user_statuses  (rows=5000000)
              -&gt;  Hash
                    -&gt;  Seq Scan on users  (rows=100000)

---------- LATERAL JOIN ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN LATERAL (
  SELECT status
  FROM user_statuses
  WHERE user_statuses.user_id = users.id
  ORDER BY created_at DESC, id DESC
  LIMIT 1
) latest ON true;

-- Plan: one index-only scan with LIMIT 1 per user, single pass

Nested Loop Left Join  (rows=100000)
  -&gt;  Seq Scan on users  (rows=100000)
  -&gt;  Limit  (rows=1 loops=100000)
        -&gt;  Index Only Scan using idx_user_statuses_user_id_created_at
              on user_statuses  (rows=1 loops=100000)
</pre>
</details>
<h3 id="without-the-index">
  
    Without the index
  
</h3>

<p>The window function and lateral join are the two strongest approaches
with our covering index. How much of that performance comes from the
index itself? I dropped it and re-ran both on all 100,000 users:</p>

<table>
<thead>
<tr>
<th>Approach</th>
<th>With index</th>
<th>Without index</th>
</tr>
</thead>
<tbody>
<tr>
<td>Window function</td>
<td>459 ms</td>
<td>1,946 ms</td>
</tr>
<tr>
<td>Lateral join</td>
<td>174 ms</td>
<td>~4 hours</td>
</tr>
</tbody>
</table>

<p>The window function is about 4x faster with the index. Because the
index <a href="https://use-the-index-luke.com/blog/2019-04/include-columns-in-btree-indexes">includes <code>status</code> as a non-key column</a>,
Postgres can do an <a href="https://use-the-index-luke.com/sql/clustering/index-only-scan-covering-index">index-only scan</a> over all 5
million rows without touching the heap at all. Without the index, it
falls back to a sequential scan plus an external merge sort on disk.</p>

<p>The lateral join went from the fastest to the slowest. With the index,
it reads one row per user: 100,000 fast lookups. Without it, each
lookup becomes a sequential scan of all 5 million rows, repeated
100,000 times.</p>

<details>
<summary>Queries and plans (without index)</summary>
<pre>
---------- WINDOW FUNCTION ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN (
  SELECT
    user_id, status,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY created_at DESC, id DESC
    ) AS row_number
  FROM user_statuses
) latest ON latest.user_id = users.id AND latest.row_number = 1;

-- Plan: sequential scan + external merge sort on disk

Hash Right Join  (rows=100000)
  -&gt;  Subquery Scan on latest  (rows=100000)
        Filter: (latest.row_number = 1)
        -&gt;  WindowAgg  (rows=100000)
              Run Condition: (row_number() &lt;= 1)
              -&gt;  Sort  (rows=5000000)
                    Sort Method: external merge
                    -&gt;  Seq Scan on user_statuses  (rows=5000000)
  -&gt;  Hash  (rows=100000)
        -&gt;  Seq Scan on users  (rows=100000)

---------- LATERAL JOIN ----------

EXPLAIN ANALYZE
SELECT users.id, users.name, latest.status
FROM users
LEFT JOIN LATERAL (
  SELECT status
  FROM user_statuses
  WHERE user_statuses.user_id = users.id
  ORDER BY created_at DESC, id DESC
  LIMIT 1
) latest ON true;

-- Plan: sequential scan of all 5 million rows per user (loops=100000)

Nested Loop Left Join  (rows=100000)
  -&gt;  Seq Scan on users  (rows=100000)
  -&gt;  Limit  (rows=1 loops=100000)
        -&gt;  Sort  (rows=1 loops=100000)
              Sort Method: top-N heapsort  Memory: 25kB
              -&gt;  Seq Scan on user_statuses  (rows=50 loops=100000)
                    Filter: (user_id = users.id)
                    Rows Removed by Filter: 4999950
</pre>
</details>

<aside class="warn">
  <p>The index is not optional. It’s what makes the whole design work.</p>
</aside>
<h2 id="filtering-users-by-status">
  
    Filtering users by status
  
</h2>

<p>A common application query is “give me all users whose current
status is X.” This needs to be fast.</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">LATERAL</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="n">status</span>
  <span class="k">FROM</span> <span class="n">user_statuses</span>
  <span class="k">WHERE</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
  <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span>
  <span class="k">LIMIT</span> <span class="mi">1</span>
<span class="p">)</span> <span class="n">latest</span> <span class="k">ON</span> <span class="k">true</span>
<span class="k">WHERE</span> <span class="n">latest</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s1">'ready_for_review'</span><span class="p">;</span>
</code></pre></div>
<p>The lateral join finds each user’s current status, then the outer
<code>WHERE</code> filters to the ones we care about. No index can express
“users whose latest status is X,” so Postgres checks every
user’s latest status.</p>

<p>For all 100,000 users, that means one index-only scan per user
and a post-filter to discard the non-matches. For a page of 15
using
<a href="https://use-the-index-luke.com/no-offset">cursor pagination</a> it stays fast, since Postgres picks
up from the last seen ID and stops as soon as it fills the page:</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="k">LATERAL</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="n">status</span>
  <span class="k">FROM</span> <span class="n">user_statuses</span>
  <span class="k">WHERE</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
  <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">,</span> <span class="n">id</span> <span class="k">DESC</span>
  <span class="k">LIMIT</span> <span class="mi">1</span>
<span class="p">)</span> <span class="n">latest</span> <span class="k">ON</span> <span class="k">true</span>
<span class="k">WHERE</span> <span class="n">latest</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s1">'ready_for_review'</span>
  <span class="k">AND</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span> <span class="o">&gt;</span> <span class="p">:</span><span class="n">last_seen_id</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
<span class="k">LIMIT</span> <span class="mi">15</span><span class="p">;</span>
</code></pre></div>
<p>How many users Postgres scans per page depends on how common the
status is. If 20% of users are currently <code>ready_for_review</code>, it
checks roughly 75 users to fill a page of 15. If the status is
rare, it scans more, but each check is a single index-only probe.
The query above runs in about 0.35 ms.</p>
<h2 id="what-this-data-model-enables">
  
    What this data model enables
  
</h2>

<p>Now that we have the full timeline of status changes, we can
answer questions that a single status column never could.</p>
<h3 id="who-was-denied-last-tuesday">
  
    Who was denied last Tuesday?
  
</h3>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">JOIN</span> <span class="n">user_statuses</span> <span class="k">ON</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s1">'denied'</span>
  <span class="k">AND</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">created_at</span> <span class="o">&gt;=</span> <span class="s1">'2026-07-14'</span>
  <span class="k">AND</span> <span class="n">user_statuses</span><span class="p">.</span><span class="n">created_at</span> <span class="o">&lt;</span> <span class="s1">'2026-07-15'</span><span class="p">;</span>
</code></pre></div>
<p>No lateral join needed here. We are querying the history directly, not
deriving the current state.</p>
<h3 id="how-long-did-users-stay-in-each-status">
  
    How long did users stay in each status?
  
</h3>

<p>This is where window functions actually shine. <code>LEAD()</code> lets us peek
at the next row in the sequence to calculate the duration of each
status:</p>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span>
  <span class="n">user_id</span><span class="p">,</span>
  <span class="n">status</span><span class="p">,</span>
  <span class="n">created_at</span> <span class="k">AS</span> <span class="n">started_at</span><span class="p">,</span>
  <span class="n">LEAD</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span> <span class="n">OVER</span> <span class="p">(</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span>
    <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">id</span>
  <span class="p">)</span> <span class="k">AS</span> <span class="n">ended_at</span><span class="p">,</span>
  <span class="n">LEAD</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span> <span class="n">OVER</span> <span class="p">(</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span>
    <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">id</span>
  <span class="p">)</span> <span class="o">-</span> <span class="n">created_at</span> <span class="k">AS</span> <span class="n">duration</span>
<span class="k">FROM</span> <span class="n">user_statuses</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">id</span><span class="p">;</span>
</code></pre></div>
<p>The last status for each user will have a <code>NULL</code> duration, which makes
sense: it’s still the current one.</p>

<p>Window functions are a natural fit here. Unlike our earlier
benchmark where we only needed the latest row per user, this query
genuinely needs every row because it computes across the full
timeline.</p>
<h3 id="which-users-were-denied-and-later-re-approved">
  
    Which users were denied and later re-approved?
  
</h3>
<div class="highlight"><pre class="highlight sql"><code><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">users</span><span class="p">.</span><span class="n">name</span>
<span class="k">FROM</span> <span class="n">users</span>
<span class="k">JOIN</span> <span class="n">user_statuses</span> <span class="n">denied</span>
  <span class="k">ON</span> <span class="n">denied</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
  <span class="k">AND</span> <span class="n">denied</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s1">'denied'</span>
<span class="k">JOIN</span> <span class="n">user_statuses</span> <span class="n">approved</span>
  <span class="k">ON</span> <span class="n">approved</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="n">users</span><span class="p">.</span><span class="n">id</span>
  <span class="k">AND</span> <span class="n">approved</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s1">'approved'</span>
  <span class="k">AND</span> <span class="n">approved</span><span class="p">.</span><span class="n">created_at</span> <span class="o">&gt;</span> <span class="n">denied</span><span class="p">.</span><span class="n">created_at</span><span class="p">;</span>
</code></pre></div>
<p>This joins the history table against itself: one join finds the
<code>denied</code> row, the other finds an <code>approved</code> row that came after
it. No derived state, just facts in the timeline.</p>
<h2 id="wrap-up">
  
    Wrap-up
  
</h2>

<p>When an attribute changes over time and those changes matter to your
domain, model it as a separate table of timestamped rows. Don’t update
a column in place and lose what was there before.</p>

<p>To query the current value, use a <code>LATERAL JOIN</code> with a composite
index. It reads one row per lookup, returns as many columns as you
need, and composes well with the rest of your query. Window
functions are a close second with the right index, thanks to a
Run Condition optimization that stops early per partition.
Correlated subqueries work for simple cases but don’t scale to
multiple columns. <code>DISTINCT ON</code> scans the entire history table
and gets slower as it grows.</p>

<p>The payoff is that the same table that gives you the current status
also gives you the full timeline, duration analysis, pattern matching,
and operational metrics. One modeling decision, many questions
answered.</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/debugging-why-your-specs-have-slowed-down">Debugging Why Your Specs Have Slowed Down</a></li>
<li><a href="https://thoughtbot.com/blog/rust-doesn-t-have-named-arguments-so-what">Rust Doesn’t Have Named Arguments. So What?</a></li>
<li><a href="https://thoughtbot.com/blog/let-rails-help-you">Let Rails Help You</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24077/17412215.gif" height="1" width="1"/>]]></content>
    <summary>Status starts as a column. Then someone asks "who was denied last Tuesday?" and the schema can't answer. Model each status change as its own row from the start, without sacrificing read performance.
</summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
  <entry>
    <title>Humid 1.0: React server-side rendering in Rails can be easy! </title>
    <link rel="alternate" href="https://feed.thoughtbot.com/link/24077/17402813/humid-1-0-react-server-side-rendering-in-rails-can-be-easy"/>
    <author>
      <name>Johny Ho</name>
    </author>
    <id>https://thoughtbot.com/blog/humid-1-0-react-server-side-rendering-in-rails-can-be-easy</id>
    <published>2026-08-04T00:00:00+00:00</published>
    <updated>2026-08-07T15:06:39Z</updated>
    <content type="html"><![CDATA[<p>I want React server-side rendering to be easy. There aren’t a lot of React
server-side rendering tools in the Rails world: React on Rails has the
largest presence, but it has many bells and whistles that I’m not ready for. I
could sidecar Node.js but I really don’t want another piece of infrastructure I
have to manage.</p>

<p>I just want something simple to use and easy to get started with. So we built
it. Today we’re announcing <a href="https://github.com/thoughtbot/humid">Humid 1.0</a>!</p>

<p><img src="https://images.thoughtbot.com/m9b70662y4czazwnr7z8q466651i_humid-icon.svg" width="150" alt="Humid"></p>

<p><a href="https://github.com/thoughtbot/humid">Humid</a> is just a few helpers for React
server-side rendering in Rails with <code>mini_racer</code>, a minimal and modern embedded
V8 for Ruby.</p>

<p>Install it</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">gem</span> <span class="s2">"humid"</span>
</code></pre></div>
<p>Write your renderer and wrap it with the <code>setHumidRenderer</code> global.</p>
<div class="highlight"><pre class="highlight jsx"><code><span class="c1">//app/assets/javascript/server_rendering.jsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">renderToString</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-dom/server</span><span class="dl">'</span>

<span class="kd">const</span> <span class="nx">Greeting</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">name</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>Hello <span class="si">{</span><span class="nx">name</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>

<span class="nf">setHumidRenderer</span><span class="p">((</span><span class="nx">json</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">props</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">json</span><span class="p">)</span>
  <span class="k">return</span> <span class="nf">renderToString</span><span class="p">(&lt;</span><span class="nc">Greeting</span> <span class="si">{</span><span class="p">...</span><span class="nx">props</span><span class="si">}</span> <span class="p">/&gt;)</span>
<span class="p">})</span>
</code></pre></div>
<p>And render it from Ruby:</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="nb">require</span> <span class="s1">'humid'</span>
<span class="nb">require</span> <span class="s1">'mini_racer'</span>

<span class="n">context</span> <span class="o">=</span> <span class="no">MiniRacer</span><span class="o">::</span><span class="no">Context</span><span class="p">.</span><span class="nf">new</span>
<span class="n">server_bundle</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"app/assets/builds/server_rendering.js"</span><span class="p">)</span>
<span class="n">props</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span> <span class="ss">name: </span><span class="s2">"World"</span> <span class="p">})</span>

<span class="no">Humid</span><span class="p">.</span><span class="nf">prepare</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="p">{</span><span class="ss">application_path: </span><span class="n">server_bundle</span><span class="p">})</span>
<span class="no">Humid</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">props</span><span class="p">)</span>
<span class="c1"># =&gt; &lt;h1&gt;Hello World&lt;/h1&gt;</span>
</code></pre></div>
<p>Best of all:</p>

<ol>
<li>There’s no separate Node.js process.</li>
<li>It’s powered by the performance of <a href="https://github.com/rubyjs/mini_racer/">mini_racer</a>.</li>
<li>There’s nothing that Humid does that you can’t do by yourself: it’s less than
200 lines of code.</li>
<li>
<a href="https://github.com/thoughtbot/humid/tree/main#call-humidrender">Instrumentation</a> and <a href="https://github.com/thoughtbot/humid/tree/main#telemetry">Telemetry</a> included.</li>
<li>You can set <code>Humid.prepare</code>‘s default options with an <a href="https://github.com/thoughtbot/humid/tree/main#configuration">initializer</a>.</li>
</ol>
<h2 id="design">
  
    Design
  
</h2>

<p>Humid is designed with two goals in mind:</p>

<ol>
<li>It’s for the common case where all data is gathered before rendering. Your
application fetches everything needed, passes it as props, and Humid returns the
rendered HTML in a single synchronous call.</li>
<li>It’s a stepping stone for when you want to scale on the edge using
<a href="https://developers.cloudflare.com/workers/reference/how-workers-works/">Cloudflare V8 isolates</a>.
<a href="https://github.com/rubyjs/mini_racer">mini_racer</a> is a bare V8 environment, if
your JS bundle works with <code>mini_racer</code>, it’ll work on Cloudflare V8 isolates.</li>
</ol>
<h2 id="caveats">
  
    Caveats
  
</h2>

<p>Humid renders synchronously. It does not support streaming or async data
fetching during render. Gather your data first, then pass it as props.</p>

<p><code>mini_racer</code> is a bare V8 environment: no window, no document, no Node builtins.
Your build will need polyfills or shims. Here’s <a href="https://github.com/thoughtbot/humid/blob/main/sample/shim.js">a working one</a> for React.</p>

<p><code>mini_racer</code> is thread safe, but not fork-safe. See <a href="https://github.com/thoughtbot/humid/tree/main#your-webserver">guidance here</a> for your webserver.</p>

<aside class="related-articles"><h2>If you enjoyed this post, you might also like:</h2>
<ul>
<li><a href="https://thoughtbot.com/blog/heroku-wearing-suspenders">Heroku Wearing Suspenders</a></li>
<li><a href="https://thoughtbot.com/blog/this-week-in-open-source-37">This Week in Open Source</a></li>
<li><a href="https://thoughtbot.com/blog/hidden-gems-suspenders">Hidden Gems: Suspenders</a></li>
</ul></aside>
<img src="https://feed.thoughtbot.com/link/24077/17402813.gif" height="1" width="1"/>]]></content>
    <summary>We're releasing Humid 1.0. A few helper methods to help with React server-side rendering in Rails with mini_racer. </summary>
    <thoughtbot:auto_social_share>true</thoughtbot:auto_social_share>
  </entry>
</feed>
