<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Juan Vasquez</title>
    <description>The latest articles on DEV Community by Juan Vasquez (@juanvqz).</description>
    <link>https://dev.to/juanvqz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F84706%2F5cce6a27-4e0a-455d-923d-bb5fa01b39e2.jpeg</url>
      <title>DEV Community: Juan Vasquez</title>
      <link>https://dev.to/juanvqz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/juanvqz"/>
    <language>en</language>
    <item>
      <title>From Turbo Streams to Turbo Morph: Simplifying Real-Time Rails</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Mon, 06 Apr 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/juanvqz/from-turbo-streams-to-turbo-morph-simplifying-real-time-rails-395d</link>
      <guid>https://dev.to/juanvqz/from-turbo-streams-to-turbo-morph-simplifying-real-time-rails-395d</guid>
      <description>&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I'm building a multitenant order management system for cafes and restaurants. Orders come in, the kitchen sees a live queue, waiters track item status — all updating in real time across multiple screens.&lt;/p&gt;

&lt;p&gt;The natural first choice in Rails? &lt;strong&gt;Turbo Streams&lt;/strong&gt; — targeted DOM updates over WebSocket. Replace this partial, append to that list, remove that element.&lt;/p&gt;

&lt;p&gt;It worked. Until it didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Almost Kept Targeted Broadcasts
&lt;/h2&gt;

&lt;p&gt;On March 13, I made a deliberate decision to &lt;strong&gt;keep&lt;/strong&gt; targeted Turbo Stream broadcasts. The infrastructure worked, the tests passed, and I'd already invested time building it. I documented the decision and moved on.&lt;/p&gt;

&lt;p&gt;Six days later, I reversed it.&lt;/p&gt;

&lt;p&gt;What changed? I started building the &lt;strong&gt;kitchen queue&lt;/strong&gt; — a live view where cooks see incoming orders. The queue needed to stay in sync with the order page, the tables view, and the takeout view. Every item status change had to update all four screens simultaneously.&lt;/p&gt;

&lt;p&gt;That's when the targeted approach fell apart. Not because of a single bug, but because the &lt;strong&gt;coordination cost grew faster than the features&lt;/strong&gt;. Each new view multiplied the number of broadcast methods, target IDs, and partials I had to keep in sync.&lt;/p&gt;

&lt;p&gt;The moment I caught myself writing the fifth broadcast method for a single status change, I knew the architecture was wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Targeted Broadcasts
&lt;/h2&gt;

&lt;p&gt;Here's what my &lt;code&gt;LineItem&lt;/code&gt; model looked like with targeted Turbo Stream broadcasts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;after_update_commit&lt;/span&gt; &lt;span class="ss"&gt;:broadcast_item_update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :saved_change_to_status?&lt;/span&gt;

&lt;span class="kp"&gt;private&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast_item_update&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_replace_to&lt;/span&gt; &lt;span class="s2"&gt;"order_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"line_item_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"line_items/line_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;item: &lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;order: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;broadcast_kitchen_update&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_spot_update&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast_kitchen_update&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"cooking"&lt;/span&gt;
    &lt;span class="n"&gt;broadcast_append_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_kitchen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"kitchen-queue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"kitchen/line_item_card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;item: &lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"ready"&lt;/span&gt;
    &lt;span class="n"&gt;broadcast_replace_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_kitchen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"kitchen_line_item_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"kitchen/line_item_card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;item: &lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"delivered"&lt;/span&gt;
    &lt;span class="n"&gt;broadcast_remove_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_kitchen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"kitchen_line_item_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast_spot_update&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_replace_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_tables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"spot_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spot_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"tables/table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;spot: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;order: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takeout?&lt;/span&gt;
    &lt;span class="n"&gt;broadcast_replace_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_takeouts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"takeout_order_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"takeouts/order_card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;order: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;strong&gt;one model&lt;/strong&gt;. The &lt;code&gt;Order&lt;/code&gt; model had a similar amount. In total, roughly &lt;strong&gt;120 lines&lt;/strong&gt; of broadcast code across two models.&lt;/p&gt;

&lt;p&gt;Every broadcast needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The correct &lt;strong&gt;channel name&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The correct &lt;strong&gt;DOM target ID&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The correct &lt;strong&gt;partial path&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The correct &lt;strong&gt;locals&lt;/strong&gt; with fresh data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the real problem: &lt;strong&gt;every new real-time feature multiplied the complexity&lt;/strong&gt;. Adding the kitchen queue meant adding broadcast methods for every status transition. Adding takeout support meant more targets, more partials, more conditionals.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bugs
&lt;/h2&gt;

&lt;p&gt;Targeted broadcasts introduced two categories of bugs that morph eliminates entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stale Data
&lt;/h3&gt;

&lt;p&gt;When a callback fires, &lt;code&gt;self&lt;/code&gt; may have fresh attributes — but &lt;strong&gt;associations are still cached in memory&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# self.status is "ready" (correct)&lt;/span&gt;
&lt;span class="c1"&gt;# self.order.line_items still has the OLD status in memory&lt;/span&gt;
&lt;span class="n"&gt;broadcast_replace_to&lt;/span&gt; &lt;span class="s2"&gt;"order_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"orders/order_summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;order: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order summary partial reads &lt;code&gt;order.line_items&lt;/code&gt; to compute readiness. Since the association is stale, it renders with &lt;strong&gt;outdated data&lt;/strong&gt;. The fix was manual &lt;code&gt;.reload&lt;/code&gt; calls scattered across the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Double Broadcasts
&lt;/h3&gt;

&lt;p&gt;When multiple models trigger broadcasts on the same commit, the same DOM target can get replaced twice in rapid succession, causing &lt;strong&gt;visible flicker&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Switch to Morph
&lt;/h2&gt;

&lt;p&gt;Turbo 8 introduced &lt;strong&gt;page refresh with morphing&lt;/strong&gt; via &lt;code&gt;turbo_refreshes_with method: :morph&lt;/code&gt;. Instead of surgically replacing individual DOM elements, it tells every subscribed browser: "re-fetch this page and I'll morph the differences."&lt;/p&gt;

&lt;p&gt;Here's the same &lt;code&gt;LineItem&lt;/code&gt; after the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;after_update_commit&lt;/span&gt; &lt;span class="ss"&gt;:broadcast_refreshes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :saved_change_to_status?&lt;/span&gt;

&lt;span class="kp"&gt;private&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast_refreshes&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"order_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_kitchen"&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_tables"&lt;/span&gt;
  &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_takeouts"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines. No partials, no target IDs, no locals, no stale data.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changed in the Views
&lt;/h2&gt;

&lt;p&gt;Each page that subscribes to real-time updates needs two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;turbo_stream_from&lt;/code&gt; tag (same as before)&lt;/li&gt;
&lt;li&gt;A morph declaration
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_refreshes_with&lt;/span&gt; &lt;span class="ss"&gt;method: :morph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scroll: :preserve&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_stream_from&lt;/span&gt; &lt;span class="s2"&gt;"store_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_kitchen"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scroll: :preserve&lt;/code&gt; part is important. &lt;strong&gt;Without it, every refresh scrolls the page to the top&lt;/strong&gt; — completely unusable for a kitchen queue that staff are actively watching during service. With it, Turbo preserves scroll position, focus, and form state across morphs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changed in the Controllers
&lt;/h2&gt;

&lt;p&gt;Before, controllers had to handle both HTML and Turbo Stream responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ready&lt;/span&gt;
  &lt;span class="vi"&gt;@line_item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_ready!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;by: &lt;/span&gt;&lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="vi"&gt;@order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;
  &lt;span class="n"&gt;respond_to&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;turbo_stream&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;order_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each action had a matching &lt;code&gt;.turbo_stream.erb&lt;/code&gt; template with its own set of &lt;code&gt;turbo_stream.replace&lt;/code&gt; calls.&lt;/p&gt;

&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ready&lt;/span&gt;
  &lt;span class="vi"&gt;@line_item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_ready!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;by: &lt;/span&gt;&lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_back&lt;/span&gt; &lt;span class="ss"&gt;fallback_location: &lt;/span&gt;&lt;span class="n"&gt;order_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"kitchen.marked_ready"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain redirects. The model callbacks handle all real-time updates. I deleted &lt;strong&gt;three Turbo Stream templates&lt;/strong&gt; and simplified every action method.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Deleted
&lt;/h2&gt;

&lt;p&gt;The migration removed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model broadcast methods&lt;/td&gt;
&lt;td&gt;~120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turbo Stream templates&lt;/td&gt;
&lt;td&gt;~30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stimulus controllers (audio/queue)&lt;/td&gt;
&lt;td&gt;~118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~268&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three Stimulus controllers were deleted because they existed solely to coordinate DOM updates that morph now handles automatically — things like updating the queue count badge or toggling empty states.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Flow After Morph
&lt;/h2&gt;

&lt;p&gt;Here's how the real-time update cycle works now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Kitchen staff taps "Listo" on a cappuccino
   → PATCH /orders/:id/line_items/:id/ready

2. Controller: mark_ready! → redirect_back

3. Model callback fires broadcast_refreshes:
   → broadcast_refresh_to order_42
   → broadcast_refresh_to store_1_kitchen
   → broadcast_refresh_to store_1_tables
   → broadcast_refresh_to store_1_takeouts

4. Every subscribed browser re-fetches its page.
   Turbo morphs the DOM diff.

   /kitchen    → card moves from "cooking" to "ready"
   /orders/42  → item badge turns green
   /tables     → table status updates
   /takeouts   → order card updates

5. No flicker. Scroll preserved. Focus preserved.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No partial coordination. No target ID matching. The server always renders the truth.&lt;/p&gt;

&lt;p&gt;One thing worth noting: &lt;code&gt;turbo_stream_from&lt;/code&gt; uses signed stream names by default, so your tenant-scoped channels (like &lt;code&gt;store_#{id}_kitchen&lt;/code&gt;) are safe from unauthorized subscriptions out of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;Here's a minimal example you can drop into any Rails 8 app with Action Cable configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/message.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Message&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;after_create_commit&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"messages"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;after_update_commit&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"messages"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;after_destroy_commit&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;broadcast_refresh_to&lt;/span&gt; &lt;span class="s2"&gt;"messages"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# app/views/messages/index.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_refreshes_with&lt;/span&gt; &lt;span class="ss"&gt;method: :morph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scroll: :preserve&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_stream_from&lt;/span&gt; &lt;span class="s2"&gt;"messages"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Messages&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;small&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;time_ago_in_words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; ago&lt;span class="nt"&gt;&amp;lt;/small&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open two browser tabs. Create a message in one — the other updates instantly. No JavaScript, no stream templates, no target IDs. That's the entire real-time layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tradeoff
&lt;/h2&gt;

&lt;p&gt;Morph re-renders the &lt;strong&gt;entire page&lt;/strong&gt; on the server for every broadcast, instead of rendering a single partial. At scale, this matters.&lt;/p&gt;

&lt;p&gt;At cafe/restaurant scale? It's negligible. A kitchen queue page with 15 items is trivial to re-render.&lt;/p&gt;

&lt;p&gt;The other tradeoff: &lt;strong&gt;no more client-side reactions to specific events&lt;/strong&gt;. With targeted streams, you could play a sound when an item was appended to the kitchen queue. With morph, you just get a re-rendered page — there's no "this specific thing changed" signal.&lt;/p&gt;

&lt;p&gt;For audio notifications, I'll need a different approach — likely a small Stimulus controller listening to a dedicated Action Cable channel. Action Cable custom channels handle this cleanly.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Use Which
&lt;/h2&gt;

&lt;p&gt;This experience gave me a clearer mental model:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Turbo Morph when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple views need to stay in sync&lt;/li&gt;
&lt;li&gt;The data being displayed has complex interdependencies&lt;/li&gt;
&lt;li&gt;You want real-time updates without managing DOM coordination&lt;/li&gt;
&lt;li&gt;Your pages are lightweight enough to re-render&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use Targeted Turbo Streams when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need to react to specific events on the client (animations, sounds)&lt;/li&gt;
&lt;li&gt;Re-rendering the full page is genuinely expensive&lt;/li&gt;
&lt;li&gt;You have a single, well-defined target that changes in isolation&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The most surprising part of this migration was how much &lt;strong&gt;incidental complexity&lt;/strong&gt; the targeted approach had introduced. Code that felt necessary — all those broadcast methods, stream templates, Stimulus controllers — turned out to be scaffolding around a coordination problem that morph solves at a lower level.&lt;/p&gt;

&lt;p&gt;I documented a decision to keep targeted broadcasts, then reversed it six days later. That's not a failure — that's the system working. The decision document forced me to articulate &lt;em&gt;why&lt;/em&gt; I was keeping the old approach, which made it obvious when the reasons no longer held.&lt;/p&gt;

&lt;p&gt;Sometimes the right move is to stop trying to be precise and let the framework do the work.&lt;/p&gt;

</description>
      <category>development</category>
    </item>
    <item>
      <title>Migrating a Rails App from Heroku to Railway</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Tue, 31 Mar 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/juanvqz/migrating-a-rails-app-from-heroku-to-railway-24jd</link>
      <guid>https://dev.to/juanvqz/migrating-a-rails-app-from-heroku-to-railway-24jd</guid>
      <description>&lt;p&gt;Last weekend I migrated my Doctors App from Heroku to Railway.&lt;/p&gt;

&lt;p&gt;It's a multi-tenant Rails app where each hospital gets its own subdomain — &lt;code&gt;one.doctors.com&lt;/code&gt;, &lt;code&gt;two.doctors.com&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Five hospitals, around 25,000 appointments, 9,700+ patients. Not huge, but not trivial either.&lt;/p&gt;

&lt;p&gt;Here's how it went, including the part where I accidentally broke the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I already had a Railway project running with a test domain (&lt;code&gt;*.juanvasquez.dev&lt;/code&gt;) from earlier experiments. The web service was deployed from GitHub and the Postgres 17 instance was co-located in &lt;code&gt;us-east4&lt;/code&gt;. Cloudflare R2 handles file storage — that stays the same regardless of where the app runs.&lt;/p&gt;

&lt;p&gt;The plan was simple: put Heroku in maintenance mode, dump the database, restore it to Railway, flip the DNS, and go home.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database restore
&lt;/h2&gt;

&lt;p&gt;First, I captured a fresh Heroku backup and downloaded it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku pg:backups:capture &lt;span class="nt"&gt;--app&lt;/span&gt; doctors
heroku pg:backups:download &lt;span class="nt"&gt;--app&lt;/span&gt; doctors &lt;span class="nt"&gt;--output&lt;/span&gt; /tmp/heroku_backup.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I wiped the Railway database and restored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wipe&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;railway-host&amp;gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;port&amp;gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; database_name &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DROP SCHEMA public CASCADE; CREATE SCHEMA public;"&lt;/span&gt;

&lt;span class="c"&gt;# Restore&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;railway-host&amp;gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;port&amp;gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; database_name /tmp/heroku_backup.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restore threw two errors — both about the &lt;code&gt;unaccent&lt;/code&gt; extension. Heroku installs extensions in a &lt;code&gt;heroku_ext&lt;/code&gt; schema that doesn't exist on Railway. The fix is to just create it manually afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;railway-host&amp;gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;port&amp;gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; database_name &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE EXTENSION IF NOT EXISTS unaccent;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else restored cleanly. I verified every table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Heroku&lt;/th&gt;
&lt;th&gt;Railway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;9,752&lt;/td&gt;
&lt;td&gt;9,752&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;appointments&lt;/td&gt;
&lt;td&gt;25,481&lt;/td&gt;
&lt;td&gt;25,481&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;addresses&lt;/td&gt;
&lt;td&gt;9,835&lt;/td&gt;
&lt;td&gt;9,835&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;patient_referrals&lt;/td&gt;
&lt;td&gt;1,211&lt;/td&gt;
&lt;td&gt;1,211&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hospitals&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 12 tables matched exactly. If you take one thing from this post: &lt;strong&gt;always verify row counts after a restore&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment I broke the database
&lt;/h2&gt;

&lt;p&gt;With the data restored, I wanted to trigger a deploy on the web service. I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--service web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That command deployed my Rails application code onto the Postgres service. It replaced the PostgreSQL 17 container with Puma. The database was now a Rails web server that couldn't handle Postgres connections.&lt;/p&gt;

&lt;p&gt;The logs told the story immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP parse error, malformed request: #&amp;lt;Puma::HttpParserError:
Invalid HTTP format, parsing fails. Are you trying to open
an SSL connection to a non-SSL Puma?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web service was trying to connect to Postgres, but Postgres was now running Puma, responding to TCP connections with HTTP errors.&lt;/p&gt;

&lt;p&gt;The fix was to roll back the Postgres service to its last good deployment. Railway's CLI doesn't have a rollback command, so I used the dashboard to roll back the deployment.&lt;/p&gt;

&lt;p&gt;After about 45 seconds, Postgres was back. Data intact. Lesson learned: &lt;strong&gt;always pass &lt;code&gt;--service web&lt;/code&gt; when deploying&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flipping the domain
&lt;/h2&gt;

&lt;p&gt;Removing the test domain was another adventure. Railway's CLI can add domains but can't delete them. I used the dashboard to remove it.&lt;/p&gt;

&lt;p&gt;Then I added the production wildcard domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway domain &lt;span class="s2"&gt;"*.doctors.com"&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt; web &lt;span class="nt"&gt;--port&lt;/span&gt; 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Railway returned the DNS records I needed. In Squarespace (my domain registrar), I added:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CNAME&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;znjcefnu.up.railway.app&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAME&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_acme-challenge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;znjcefnu.authorize.railwaydns.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There was also a &lt;code&gt;_railway-verify&lt;/code&gt; record for domain ownership. I initially tried adding it as a &lt;code&gt;CNAME&lt;/code&gt;, but Squarespace rejected the value — it's actually a &lt;strong&gt;TXT record&lt;/strong&gt;, not a &lt;code&gt;CNAME&lt;/code&gt;. Small thing, but it tripped me up.&lt;/p&gt;

&lt;p&gt;DNS propagated fast. Within a couple of minutes, Railway confirmed the domain was verified and SSL was provisioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: RACK_ENV
&lt;/h2&gt;

&lt;p&gt;The first request to &lt;code&gt;demo.doctors.com&lt;/code&gt; returned a 500. I checked the logs and saw... a Rails development error page. &lt;code&gt;RACK_ENV&lt;/code&gt; was set to &lt;code&gt;development&lt;/code&gt;. A quick variable update and redeploy fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway variable &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;RACK_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="nt"&gt;--service&lt;/span&gt; web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then all five hospital subdomains came back with 200s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trial plan limitations
&lt;/h2&gt;

&lt;p&gt;Railway's trial plan only allows &lt;strong&gt;one custom domain per service&lt;/strong&gt;. The wildcard &lt;code&gt;*.doctors.com&lt;/code&gt; uses that single slot, which works great for multi-tenancy — every subdomain routes correctly. But I can't also add the root domain &lt;code&gt;doctors.com&lt;/code&gt;. For now, I'll handle that with a redirect at the registrar level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Heroku&lt;/th&gt;
&lt;th&gt;Railway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web service&lt;/td&gt;
&lt;td&gt;$7/mo (Basic dyno)&lt;/td&gt;
&lt;td&gt;Usage-based (~$5/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;$5/mo (Mini)&lt;/td&gt;
&lt;td&gt;Included (500MB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom domains&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;1 per service (trial)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome buildpack&lt;/td&gt;
&lt;td&gt;Required for old PDF setup&lt;/td&gt;
&lt;td&gt;Not needed (using Prawn now)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For my scale, Railway is slightly cheaper. The real win is simplicity — no buildpack configuration, no add-on marketplace to navigate, and Postgres is just there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I also did
&lt;/h2&gt;

&lt;p&gt;While I was at it, I replaced Sentry with &lt;a href="https://app.honeybadger.io/users/sign_up?referred_by=8eTFBiZ7EUHt8iCF" rel="noopener noreferrer"&gt;Honeybadger&lt;/a&gt; &lt;em&gt;(referral link)&lt;/em&gt; for error tracking. Sentry's initializer still referenced Heroku env vars, so it was a good time to clean house. Honeybadger has a free plan, built-in uptime monitoring, and the Rails setup is just a YAML file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/honeybadger.yml&lt;/span&gt;
&lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch("HONEYBADGER_API_KEY", "") %&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= Rails.env %&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;exceptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= Rails.env.production? %&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also updated the CI pipeline — upgraded Postgres from 10.13 to 17 (matching production) and Node.js from 20 to 22 (matching &lt;code&gt;package.json&lt;/code&gt;). Removed the Puppeteer and Chrome setup steps that were left over from when the app used Grover for PDF generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd tell myself before starting
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify row counts after every restore.&lt;/strong&gt; Don't trust "no errors" — count the rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always specify &lt;code&gt;--service&lt;/code&gt; when running Railway CLI commands.&lt;/strong&gt; Especially &lt;code&gt;railway up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Railway's CLI can't do everything.&lt;/strong&gt; Domain deletion and deployment rollbacks need to be done through the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;railway run&lt;/code&gt; executes locally&lt;/strong&gt;, not on Railway's infrastructure. Use &lt;code&gt;railway shell&lt;/code&gt; for remote access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heroku's &lt;code&gt;heroku_ext&lt;/code&gt; schema for extensions doesn't exist on Railway.&lt;/strong&gt; Expect restore errors for extensions, and re-create them manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your RACK_ENV.&lt;/strong&gt; It seems obvious, but it's easy to forget when you're focused on the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;_railway-verify&lt;/code&gt; DNS record is a TXT record&lt;/strong&gt;, even though it looks like it could be a CNAME. Your registrar will reject it if you pick the wrong type.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fair warning
&lt;/h2&gt;

&lt;p&gt;Since migrating, I've seen reports from other developers that give me pause. One team experienced &lt;a href="https://www.reddit.com/r/rails/comments/1s51mfc/railway_vs_render_heroku_digital_ocean_fly_etc/" rel="noopener noreferrer"&gt;persistent 150–200ms request queuing&lt;/a&gt; on Railway that they couldn't resolve even with Pro plan support — response times that were 40ms on Heroku, Render, and DigitalOcean. Another long-time customer &lt;a href="https://x.com/euboid/status/2038729202602500376" rel="noopener noreferrer"&gt;reported a caching misconfiguration&lt;/a&gt; that leaked user data between accounts, on top of weeks of near-daily incidents.&lt;/p&gt;

&lt;p&gt;I measured my own response times after reading these reports, and for my scale they're good enough. But if you're running something larger, do thorough stress testing before committing, and have a rollback plan. Railway is young, and that cuts both ways: fast iteration, but also growing pains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;The whole migration took about an hour. Most of that was waiting for DNS propagation and debugging the Postgres incident. The actual work — dump, restore, set variables, flip DNS — was maybe 30 minutes.&lt;/p&gt;

&lt;p&gt;Railway feels like what Heroku should have become. The dashboard is clean, deploys are fast, and the Postgres integration just works. I miss &lt;code&gt;heroku run&lt;/code&gt; (Railway's local execution model is confusing at first), but &lt;code&gt;railway shell&lt;/code&gt; covers most cases.&lt;/p&gt;

&lt;p&gt;For a small multi-tenant Rails app like mine, it's a good fit. But I'm keeping my Heroku knowledge fresh — just in case.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>railway</category>
      <category>heroku</category>
    </item>
    <item>
      <title>Alacritty-Themes release 4.1.2 🌈😍</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Tue, 21 Sep 2021 16:28:23 +0000</pubDate>
      <link>https://dev.to/juanvqz/alacritty-themes-release-4-1-2-2hp7</link>
      <guid>https://dev.to/juanvqz/alacritty-themes-release-4-1-2-2hp7</guid>
      <description>&lt;p&gt;Today, was released alacritty-themes 4.1.2&lt;/p&gt;

&lt;p&gt;Bug Fixes&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stop removing existing comments on the alacritty file &lt;a href="https://github.com/rajasegar/alacritty-themes/pull/42" rel="noopener noreferrer"&gt;#42&lt;/a&gt; &lt;a href="https://github.com/rajasegar/alacritty-themes/commit/98a5d68d4be76eb8a7e9ccd9277ada5a44ef71e6" rel="noopener noreferrer"&gt;98a5d68&lt;/a&gt;, closes &lt;a href="https://github.com/rajasegar/alacritty-themes/issues/30" rel="noopener noreferrer"&gt;#30&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;test it here &lt;a href="https://github.com/rajasegar/alacritty-themes" rel="noopener noreferrer"&gt;Themes for Alacritty: A cross-platform GPU-accelerated Terminal emulator&lt;/a&gt; &lt;/p&gt;

</description>
      <category>alacritty</category>
    </item>
    <item>
      <title>Dogs are most responsive to commands spoken in Spanish.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Fri, 13 Aug 2021 12:39:03 +0000</pubDate>
      <link>https://dev.to/juanvqz/dogs-are-most-responsive-to-commands-spoken-in-spanish-4k2o</link>
      <guid>https://dev.to/juanvqz/dogs-are-most-responsive-to-commands-spoken-in-spanish-4k2o</guid>
      <description>&lt;p&gt;Dogs are most responsive to commands spoken in Spanish.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>if you cry in space. the tears will stick to your face.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Thu, 12 Aug 2021 19:05:09 +0000</pubDate>
      <link>https://dev.to/juanvqz/if-you-cry-in-space-the-tears-will-stick-to-your-face-25ap</link>
      <guid>https://dev.to/juanvqz/if-you-cry-in-space-the-tears-will-stick-to-your-face-25ap</guid>
      <description>&lt;p&gt;if you cry in space. the tears will stick to your face.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>The first pair of scissors manufactured was left handed.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Wed, 11 Aug 2021 12:59:22 +0000</pubDate>
      <link>https://dev.to/juanvqz/the-first-pair-of-scissors-manufactured-was-left-handed-5ai8</link>
      <guid>https://dev.to/juanvqz/the-first-pair-of-scissors-manufactured-was-left-handed-5ai8</guid>
      <description>&lt;p&gt;The first pair of scissors manufactured was left handed.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>The seahorse is the only fish that can swim backwards.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Tue, 10 Aug 2021 12:27:16 +0000</pubDate>
      <link>https://dev.to/juanvqz/the-seahorse-is-the-only-fish-that-can-swim-backwards-5eng</link>
      <guid>https://dev.to/juanvqz/the-seahorse-is-the-only-fish-that-can-swim-backwards-5eng</guid>
      <description>&lt;p&gt;The seahorse is the only fish that can swim backwards.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>Just like fingerprints, everyone has a unique tongue print.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Tue, 10 Aug 2021 12:26:47 +0000</pubDate>
      <link>https://dev.to/juanvqz/just-like-fingerprints-everyone-has-a-unique-tongue-print-5e6c</link>
      <guid>https://dev.to/juanvqz/just-like-fingerprints-everyone-has-a-unique-tongue-print-5e6c</guid>
      <description>&lt;p&gt;Just like fingerprints, everyone has a unique tongue print.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>The Queen of England's password number is 1.</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Tue, 10 Aug 2021 12:23:28 +0000</pubDate>
      <link>https://dev.to/juanvqz/the-queen-of-england-s-password-number-is-1-2l8g</link>
      <guid>https://dev.to/juanvqz/the-queen-of-england-s-password-number-is-1-2l8g</guid>
      <description>&lt;p&gt;The Queen of England's password number is 1.&lt;/p&gt;

&lt;p&gt;True 🟢 False 🔴 ?&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>Error on rails assets:precompile on production (solved)</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Sun, 08 Aug 2021 14:45:40 +0000</pubDate>
      <link>https://dev.to/juanvqz/error-on-rails-assets-precompile-on-production-solved-f</link>
      <guid>https://dev.to/juanvqz/error-on-rails-assets-precompile-on-production-solved-f</guid>
      <description>&lt;p&gt;Once I started the project I update the &lt;strong&gt;@rails/webpacker&lt;/strong&gt; npm package from version 5 to version &lt;code&gt;^6.0.0-pre.2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Thinking it was the last possible version, which btw it works great on development mode, the problem was when I tried to upload to Heroku (production) I got the following error message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ERROR &lt;span class="k"&gt;in &lt;/span&gt;Conflict: Multiple assets emit different content to the same filename js/.br
ERROR &lt;span class="k"&gt;in &lt;/span&gt;Conflict: Multiple assets emit different content to the same filename .br
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hadn't any idea 💡 what the heck is the &lt;code&gt;.br&lt;/code&gt; format, in fact, I haven’t heard anything about it.&lt;/p&gt;

&lt;p&gt;After a little bit of research &lt;a href="https://en.m.wikipedia.org/wiki/Brotli" rel="noopener noreferrer"&gt;here&lt;/a&gt; we have what the &lt;code&gt;.br&lt;/code&gt; format is.&lt;/p&gt;

&lt;p&gt;At the end, I just update the webpacker version to &lt;code&gt;6.0.0-beta.7&lt;/code&gt; and now it works as expected in a production environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/JuanVqz/lalo_menu/commit/f805838af4d2dc0429630396dca65cb5dad49e7d" rel="noopener noreferrer"&gt;commit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: BTW, was fixed up in this &lt;a href="https://github.com/rails/webpacker/pull/2830/" rel="noopener noreferrer"&gt;PR&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;See you next time! &lt;/p&gt;

</description>
      <category>opensource</category>
      <category>lalomenu</category>
    </item>
    <item>
      <title>E-mail was invented before the World Wide Web?</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Wed, 04 Aug 2021 20:24:58 +0000</pubDate>
      <link>https://dev.to/juanvqz/e-mail-was-invented-before-the-world-wide-web-982</link>
      <guid>https://dev.to/juanvqz/e-mail-was-invented-before-the-world-wide-web-982</guid>
      <description>&lt;p&gt;E-mail was invented before the World Wide Web?&lt;/p&gt;

&lt;p&gt;🟢 True&lt;br&gt;
🔴 False&lt;/p&gt;

&lt;p&gt;If you know why, how, when, or whatever interesting things related to the question, Please share with us.&lt;/p&gt;

</description>
      <category>question</category>
    </item>
    <item>
      <title>We got a tailwind CSS template</title>
      <dc:creator>Juan Vasquez</dc:creator>
      <pubDate>Sat, 24 Jul 2021 05:53:45 +0000</pubDate>
      <link>https://dev.to/juanvqz/lalo-menu-we-got-the-template-2o06</link>
      <guid>https://dev.to/juanvqz/lalo-menu-we-got-the-template-2o06</guid>
      <description>&lt;p&gt;I'm used to use Bootstrap CSS framework, I have been wanted to work with tailwind and today I did it, it was shocking to get it working and I'm not sure if it was my computer or me but I felt it slow. I need to do more research about it.&lt;/p&gt;

&lt;p&gt;Well, we finally have the Tailwind Layout. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dypfc36blhgkn2qygqs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dypfc36blhgkn2qygqs.png" alt="Tailwind Template" width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/channel/UCmXVXfidLZQkppLPaATcHag" rel="noopener noreferrer"&gt;Better Dev&lt;/a&gt; Thank you for this useful &lt;a href="https://www.youtube.com/watch?v=DOOoS6GHDw8" rel="noopener noreferrer"&gt;tailwind tutorial.&lt;/a&gt;&lt;br&gt;
:party: &lt;/p&gt;

</description>
      <category>rails</category>
      <category>tailwindcss</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
