<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/feed/style" type="text/xsl"?>
<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom">
  <id>tag:stanko.io,2005:/feed</id>
  <link rel="alternate" type="text/html" href="https://stanko.io/"/>
  <link rel="self" type="application/atom+xml" href="https://stanko.io/feed"/>
  <title>Stanko Krtalic Rusendic</title>
  <updated>2026-03-12T12:11:10Z</updated>
  <entry>
    <id>tag:stanko.io,2005:Snap/29</id>
    <published>2026-01-28T10:09:11Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-vEnaIJhYkbKj"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/28</id>
    <published>2026-01-28T09:43:00Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-avMwbBvCzqtF"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/27</id>
    <published>2026-01-27T16:15:25Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-6Q7N3UsKMi0j"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/26</id>
    <published>2026-01-27T15:03:14Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-ygGhGgDlhgOz"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/25</id>
    <published>2026-01-27T15:02:31Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-UcD6A2ZdQyiM"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/24</id>
    <published>2026-01-27T15:01:05Z</published>
    <updated>2026-03-08T17:45:33Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/venice-UMXapsuRpLjh"/>
    <title>Venice</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/30</id>
    <published>2026-01-21T10:01:39Z</published>
    <updated>2026-03-08T17:47:31Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/zsa-voyager-lego-gameboy-dmg-GxhDOhG57sQd"/>
    <title>ZSA Voyager &amp; Lego Gameboy DMG</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/23</id>
    <published>2026-01-07T11:04:34Z</published>
    <updated>2026-03-09T08:42:37Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/robin-in-the-snow-PmdBY4grPtdX"/>
    <title>Robin in the snow</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/22</id>
    <published>2025-10-26T08:38:49Z</published>
    <updated>2026-03-08T17:18:53Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/halloween-2rDZGjPjjybU"/>
    <title>Halloween</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/21</id>
    <published>2025-09-05T09:34:52Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-jDHEarezVLi2"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/20</id>
    <published>2025-09-05T08:33:21Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-Oo0vQbjHNI7Z"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/19</id>
    <published>2025-09-05T08:28:42Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-oLi46yD4eOd9"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/18</id>
    <published>2025-09-05T08:26:29Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-6buxiicObFiH"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/17</id>
    <published>2025-09-05T08:25:55Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-MXfBXsv8BDC7"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/16</id>
    <published>2025-09-04T21:10:15Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-mMCLYWCHXu0b"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/15</id>
    <published>2025-09-04T08:28:52Z</published>
    <updated>2026-03-08T17:15:54Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/amsterdam-5hjfLmGYrdBW"/>
    <title>Amsterdam</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/14</id>
    <published>2025-08-24T05:38:55Z</published>
    <updated>2026-03-08T17:08:57Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-cRWYytJYzbCa"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/13</id>
    <published>2025-08-23T19:46:20Z</published>
    <updated>2026-03-08T17:08:57Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-IkJ8BfKQ5QfD"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/12</id>
    <published>2025-08-23T16:49:51Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-DZfwxabi8BAJ"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/11</id>
    <published>2025-08-23T16:43:01Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-5PDKDkuvvxUC"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/10</id>
    <published>2025-08-23T16:16:14Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-Q1tMQy7dMVGc"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/9</id>
    <published>2025-08-23T15:03:40Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-AFSuKHQGFPV4"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/8</id>
    <published>2025-08-23T13:14:41Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-uL3XAkufdcrz"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/7</id>
    <published>2025-08-23T13:14:18Z</published>
    <updated>2026-03-08T17:08:56Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-UId9264Na1K4"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/6</id>
    <published>2025-08-23T13:12:25Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-SbrH2NnK0EN5"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/5</id>
    <published>2025-08-23T13:12:03Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-qlUyvXjMKSAY"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/4</id>
    <published>2025-08-23T12:22:54Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-j0eCfpl2p8bg"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/3</id>
    <published>2025-08-23T08:15:11Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-FlIY4gXIN8f1"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/2</id>
    <published>2025-08-23T07:50:20Z</published>
    <updated>2026-03-08T17:06:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-khlmHHkmuPCM"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Snap/1</id>
    <published>2025-08-23T07:44:58Z</published>
    <updated>2026-03-08T17:06:21Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/snaps/soca-wTtw2YEvSnRm"/>
    <title>Soča</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  
&lt;/div&gt;
</content>
    <summary type="html"/>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/46</id>
    <published>2025-06-12T14:00:00Z</published>
    <updated>2026-03-11T13:32:32Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/clean-air-ai-AHcddmIf21lt"/>
    <title>Clean air &amp; AI</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;One of my favorite activities is hiking. I'm fortunate to live close to a mountain where I can go for a hike any time I want - often early in the morning, before work.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--85d2464deb42b1b7b2bde650fbf36fbdfeb592a7" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYwLCJwdXIiOiJibG9iX2lkIn19--f916c48d344242cb494c9fdeba02cec18455fe71/IMG_8969.jpeg" filename="IMG_8969.jpeg" filesize="3700690" width="3024" height="4032" previewable="true" presentation="gallery" caption="A winding road on the way to Medvednica"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A winding road on the way to Medvednica" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYwLCJwdXIiOiJibG9iX2lkIn19--f916c48d344242cb494c9fdeba02cec18455fe71/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/IMG_8969.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A winding road on the way to Medvednica
  &lt;/figcaption&gt;
&lt;/figure&gt;I love the trek over hills and valleys, through forests and streams, the creaking of trees in the wind, the rustling of leaves, the murmur of streams, and the discussions with friends along the way.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I love the destination - the view, the tranquility, the bean stew with sausage at a hiking lodge, cuddling up to a fireplace in the winter.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But most of all I love the mountain air.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a91ef3269c8c2f19dc65567a3a937126e593e360" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYxLCJwdXIiOiJibG9iX2lkIn19--2eae1887cfb983b86368dd136127af6385edfea2/IMG_8385.jpeg" filename="IMG_8385.jpeg" filesize="1986332" width="3024" height="4032" previewable="true" presentation="gallery" caption="Zagreb, as seen from Sljeme, covered with clouds"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Zagreb, as seen from Sljeme, covered with clouds" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYxLCJwdXIiOiJibG9iX2lkIn19--2eae1887cfb983b86368dd136127af6385edfea2/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/IMG_8385.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Zagreb, as seen from Sljeme, covered with clouds
  &lt;/figcaption&gt;
&lt;/figure&gt;It smells different, it’s light, relaxing, refreshing, and clean.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;The difference is most striking on the way down - at one point, the air starts to feel heavy, turns dusty, and takes on an odor.&lt;br&gt;&lt;/p&gt;&lt;p&gt;One winter morning, as the trail gave way to pavement and the outskirts of the city, a sharp sulfur smell - almost like gunpowder - hit me and stayed with me all the way home.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Something wasn’t right. The air wasn’t usually that bad. I checked the AQI for Zagreb - it was the fifth worst in the world that day.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I immediately bought a cheap air purifier off IKEA and decided to make an air quality monitor.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--d3c4a212ed8fb3f9a193123f67bd2cd594b3e561" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYyLCJwdXIiOiJibG9iX2lkIn19--3c5bd9225d92f4ebe914a50ee82a0003ff0032a6/IMG_0769.jpeg" filename="IMG_0769.jpeg" filesize="2460448" width="3024" height="4032" previewable="true" presentation="gallery" caption="Breadboard with the first prototype of my air quality monitor"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Breadboard with the first prototype of my air quality monitor" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYyLCJwdXIiOiJibG9iX2lkIn19--3c5bd9225d92f4ebe914a50ee82a0003ff0032a6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/IMG_0769.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Breadboard with the first prototype of my air quality monitor
  &lt;/figcaption&gt;
&lt;/figure&gt;I made a quick prototype using parts I bought off AliExpress. After ironing out a few kinks, I ended up with something functional - &lt;a href="https://github.com/monorkin/air_quality_box"&gt;Air Quality Box&lt;/a&gt;.&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--0b730f8d420d2a4a4d531b1d216578007fdaca4d" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYzLCJwdXIiOiJibG9iX2lkIn19--67150f7333736b7337967a0ef6cf9fe53daa6dd0/776D4C55-FE36-468F-B480-6A6B2CDB9E0E.jpeg" filename="776D4C55-FE36-468F-B480-6A6B2CDB9E0E.jpeg" filesize="1574257" width="2096" height="3724" previewable="true" presentation="gallery" caption="The Air Quality Box"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The Air Quality Box" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjYzLCJwdXIiOiJibG9iX2lkIn19--67150f7333736b7337967a0ef6cf9fe53daa6dd0/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/776D4C55-FE36-468F-B480-6A6B2CDB9E0E.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Air Quality Box
  &lt;/figcaption&gt;
&lt;/figure&gt;To turn the rat's nest of a prototype into something presentable, I designed a custom PCB that connected all the components and packed them as tightly as possible in a enclosure that I printed. It was as small as I could make it given the parts and air flow constraints needed for everything to work properly.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--0a85430d696a17d2601895b27d1761445dddc416" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY0LCJwdXIiOiJibG9iX2lkIn19--beb42a67340ea634d5c696763f67a4f7ca79569c/ECFAC0D9-983B-4EAE-9A7B-5CB10D2A527C.jpeg" filename="ECFAC0D9-983B-4EAE-9A7B-5CB10D2A527C.jpeg" filesize="1931925" width="2096" height="3724" previewable="true" presentation="gallery" caption="The tightly packed inside of the Air Quality Box"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The tightly packed inside of the Air Quality Box" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY0LCJwdXIiOiJibG9iX2lkIn19--beb42a67340ea634d5c696763f67a4f7ca79569c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/ECFAC0D9-983B-4EAE-9A7B-5CB10D2A527C.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The tightly packed inside of the Air Quality Box
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;It integrated with Home Assistant and displayed measurements on its screen. This allowed me to automate my IKEA air purifier, and I could read the air quality right from my desk without any app.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;That little boxed served me for four years and taught me a lot about air quality:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Higher CO2 levels in a room cause headaches and drowsiness&lt;/li&gt;&lt;li&gt;Air with very low particulares and 40% humidity feels like mountain air&lt;/li&gt;&lt;li&gt;VOCs and high humidity make the air feel heavy&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;But it had two major flaws.&lt;br&gt;First, its design was very industrial - &lt;i&gt;&lt;em&gt;people either loved it or hated it&lt;/em&gt;&lt;/i&gt;.&lt;br&gt;Second, the UI was clunky - &lt;i&gt;&lt;em&gt;you couldn’t just glance at it and see what’s wrong, you had to wait for all the measurements to cycle through.&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;At first, I started working on a V2 which was heavily inspired by IKEA’s Vindriktning. It had a single light bar that told you an overall score of the air quality, and a small screen at the top that showed what needed fixing.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I came across the Awair Element. It had everything I wanted - a beautiful enclosure, it shows everything at a glance, it can integrate with Home Assistant. So I just bought one, and I have been very happy with it for the past 6 months.&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--5c0a8a38b695dc9bec13e832b6a535df6f279fe2" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY1LCJwdXIiOiJibG9iX2lkIn19--b5f7b113813f81344084a6b4cf9baf21fbedc9d8/20250610_174929.jpg" filename="20250610_174929.jpg" filesize="1548411" width="4000" height="1848" previewable="true" presentation="gallery" caption="The Awair Element that I bought"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpg"&gt;

      &lt;img alt="The Awair Element that I bought" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY1LCJwdXIiOiJibG9iX2lkIn19--b5f7b113813f81344084a6b4cf9baf21fbedc9d8/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--97c88e11642e16f54e1abf60d0fe43b8490e1f40/20250610_174929.jpg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Awair Element that I bought
  &lt;/figcaption&gt;
&lt;/figure&gt;Its default screen shows five bars and a number. The number is the score of the air quality based on all measurements. Each bar represents a measurement: temperature, humidity, CO2, VOC, and particulates. When a value is within the target range, the bar shows a single dot. If it’s too high or too low, it displays two to five dots, depending on how far it has drifted.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I just have one minor annoyance with this interface - you can't tell if a measurement is too low or too high at a glance.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Say the humidity bar shows three dots - is it too humid or too dry? To find out, I have to pull out my phone, open the app, and read the value. Or I can reach around the device, press a button a few times until it displays the humidity measurement, and then cycle back to the score display. It isn't terrible, just annoying.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But I had an idea for how to fix this. I could create an app that lives in the system tray and shows the current air quality score. When clicked, it expands to show all measurements. That way I can quickly check what's wrong - no phone, no fiddling with buttons.&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NjY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--704b207595e321cb0d6a60650d72f35597dcb45e" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY2LCJwdXIiOiJibG9iX2lkIn19--c7990a9c00c94f9ecd043f2a795947cd0525cebe/Pasted%20image%2020250611155058.png" filename="Pasted image 20250611155058.png" filesize="202858" width="464" height="534" previewable="true" presentation="gallery" caption="Demo of the system tray app"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Demo of the system tray app" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY2LCJwdXIiOiJibG9iX2lkIn19--c7990a9c00c94f9ecd043f2a795947cd0525cebe/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250611155058.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the system tray app
  &lt;/figcaption&gt;
&lt;/figure&gt;This wouldn't be my first Linux Desktop app. Years ago, I made one using &lt;a href="https://wiki.archlinux.org/title/Qt"&gt;Qt&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/QML"&gt;QML&lt;/a&gt; and &lt;a href="https://www.rust-lang.org/"&gt;Rust&lt;/a&gt;, so I knew what I had to do.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;There would be two parts to the app - the app itself and the system tray widget. The app discovers air quality monitors on the network, gathers measurements, and handles settings. The system tray widget displays the gathered measurements. The two would communicate with each other via &lt;a href="https://wiki.archlinux.org/title/D-Bus"&gt;D-Bus&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"&gt;Over the last few weeks, I've been sharpening my Go skills&lt;/a&gt;, so I decided to write this app in &lt;a href="https://go.dev/"&gt;Go&lt;/a&gt;. And since I use &lt;a href="https://wiki.archlinux.org/title/GNOME"&gt;GNOME&lt;/a&gt; as my Desktop environment these days, I went with &lt;a href="https://wiki.archlinux.org/title/GTK"&gt;GTK&lt;/a&gt; instead of Qt - it fits in better visually.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Which brings me to AI.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Until now, I've used AI as a pair programmer. I’m in the driver’s seat, and when I want to bounce ideas around or look something up, I ask the AI.&lt;br&gt;&lt;/p&gt;&lt;p&gt;To me, that’s the most natural way to work with an AI. It allows me to stay in flow - I can focused on keeping the code clear, modeling the domain, and solving the real problem, while the AI allows me to tap into a vast pool of knowledge in a moment's notice. No more digging through SEO spam for that one useful link - I get exactly what I need, when I need it.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Njc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--0a5bdbdf0bd9249218d30588c01ef55724598e8d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY3LCJwdXIiOiJibG9iX2lkIn19--76f5d66a1f19438df659e5fecf5b5181ce755760/Pasted%20image%2020250612094946.png" filename="Pasted image 20250612094946.png" filesize="949949" width="2801" height="1989" previewable="true" presentation="gallery" caption="ChatGPT and NVIM side by side"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="ChatGPT and NVIM side by side" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY3LCJwdXIiOiJibG9iX2lkIn19--76f5d66a1f19438df659e5fecf5b5181ce755760/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250612094946.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      ChatGPT and NVIM side by side
  &lt;/figcaption&gt;
&lt;/figure&gt;This approach helped me a lot while learning Go. Whenever I hit a wall, I could ask the AI how it would solve the problem and get a few new things to read up on. In a way, it felt like having a mentor.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;This time, I decided to flip things around and let the AI drive while I guide it. I wanted to see if there was a better way to work with AI than what I'd been using. So I installed &lt;a href="https://docs.anthropic.com/en/docs/claude-code/overview"&gt;Claude Code&lt;/a&gt; and bought $50 worth of API credits.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;(Since this week, if you have a Pro subscription to Claude, Claude Code is included - no API credits needed)&lt;/em&gt;&lt;/i&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Njg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--7959b83583d975f12faca65a9d32e96daa850a1a" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY4LCJwdXIiOiJibG9iX2lkIn19--25f4b7039e4455a298c19b50ff89f54b1bc7933e/Pasted%20image%2020250612103759.png" filename="Pasted image 20250612103759.png" filesize="470668" width="1884" height="982" previewable="true" presentation="gallery" caption="The initial prompt given to Claude Code"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The initial prompt given to Claude Code" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY4LCJwdXIiOiJibG9iX2lkIn19--25f4b7039e4455a298c19b50ff89f54b1bc7933e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250612103759.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The initial prompt given to Claude Code
  &lt;/figcaption&gt;
&lt;/figure&gt;And that's how I - or rather &lt;i&gt;&lt;em&gt;we&lt;/em&gt;&lt;/i&gt; - made &lt;a href="https://github.com/monorkin/gnome-desktop-air-monitor"&gt;GNOME Desktop Air Monitor&lt;/a&gt;.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Njk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--62461c86c9e314b3811debb937328fb38741b807" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY5LCJwdXIiOiJibG9iX2lkIn19--7ac1d262622f14c3aeef376b02b38143a7e4fccd/Screencast%20From%202025-06-10%2017-24-02%20(1).mp4" filename="Screencast From 2025-06-10 17-24-02 (1).mp4" filesize="1281155" width="880.0" height="716.0" previewable="true" presentation="gallery" caption="Demo of GNOME Desktop Air Monitor"&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY5LCJwdXIiOiJibG9iX2lkIn19--7ac1d262622f14c3aeef376b02b38143a7e4fccd/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-06-10%2017-24-02%20(1).mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjY5LCJwdXIiOiJibG9iX2lkIn19--7ac1d262622f14c3aeef376b02b38143a7e4fccd/Screencast%20From%202025-06-10%2017-24-02%20(1).mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of GNOME Desktop Air Monitor
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I didn't like this way of us working together - for me, the pair programming approach just works better. Letting the AI drive seemed to amplify both of our weaknesses.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;First, the AI is very goal-oriented.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;It only cares about the outcome - in many ways it behaves like a &lt;a href="https://en.wikipedia.org/wiki/Greedy_algorithm"&gt;greedy algorithm&lt;/a&gt;, making choices that seem best in the moment, without regard for long-term consequences.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This isn't bad per se. Sometimes this leads to efficient, pragmatic solutions. But more often, it results in &lt;a href="https://en.wikipedia.org/wiki/Spaghetti_code"&gt;spaghetti code&lt;/a&gt; - one hack piled on top of another, just to reach the goal. It doesn't care how maintainable, extendable or readable the solution is - that isn't its goal.&lt;br&gt;&lt;/p&gt;&lt;p&gt;To counter that, I had to be very explicit. I had to explain not just &lt;i&gt;&lt;em&gt;what&lt;/em&gt;&lt;/i&gt; I wanted it to do, but also &lt;i&gt;&lt;em&gt;how&lt;/em&gt;&lt;/i&gt; I wanted it to do it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;For example, I asked it to create an index screen that shows all devices, then a show screen for individual devices, and finally a settings screen.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The result? One giant app.go file. All three screens were in it. All their logic, all the app setup and window management, everything. There was an App struct that held the state of every button, list and graph that could ever appear, with cryptic names like "button", "settingsButton", "actionList", etc. There were three methods - "showIndex", "showSettings" and "showDevice" - that were nearly identical, each resets the state of every UI element before switching screens.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I had to explicitly ask it: &lt;i&gt;&lt;em&gt;"Do you think it would be better to create a struct for each screen and keep its state there? You could add a reset and a show method there too"&lt;/em&gt;&lt;/i&gt;. It agreed - and refactored the code.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I tried to improve the naming in the same way. But it kept giving me worse and worse suggestions until I gave up and renamed everything manually.&lt;br&gt;&lt;/p&gt;&lt;p&gt;By the end, my prompts had become so detailed that I felt like I was micro-managing someone. And that's just not how I want to work. For me, trust - in someone doing their job and doing it well - is essential. If that trust isn't there, then explaining exactly what needs to be done is often more work than just doing it myself.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I talked to a friend about this and he said something along the lines of &lt;i&gt;&lt;em&gt;"you have to treat it like a junior developer"&lt;/em&gt;&lt;/i&gt;. But that's the problem - &lt;i&gt;&lt;em&gt;it isn't a junior developer&lt;/em&gt;&lt;/i&gt;. It doesn't learn from its mistakes. You can't explain to it in which situation one approach is better than another. It won't conclude that it produced garbage or that it's painting itself into a corner - as long as the goal was &lt;i&gt;&lt;em&gt;technically&lt;/em&gt;&lt;/i&gt; achieved.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Second, it doesn't understand the sunken cost fallacy.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;The AI will almost never give up on a solution. It will almost always build on top of what already exists. It doesn't question, it doesn't suggest, it just marches on. In that regard, it's similar to a junior developer.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This gets worse and worse as you iterate on your project. At some point you have to step in and explain to it that the current approach just doesn't work, and that it should try something else.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I didn’t really see this as a serious issue until my girlfriend - who can't code - asked ChatGPT to help her build an iOS app. She described what she wanted, and it generated a React Native app that did exactly what she asked. Then she asked it to add live notifications - at the time, an iOS-only feature that required native code to get it working. Instead of switching tools, it piled on hack after hack to make it work. It ultimately failed.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I told it to recreate the app in Swift - and it nailed it on the first try.&lt;/p&gt;&lt;p&gt;If I didn't know what it had to do - and didn't tell it explicitly - we'd still be spinning around in circles, trying every hack known to man or machine.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Third, it doesn't generalize.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;I often found myself in awe of both its brilliance and its stupidity in the span of five lines of code. It would implement a pattern that felt like the right solution - elegant, compresses the complexity just enough, clean - but only in the narrow context of the problem it was solving at the time. It never generalized the pattern or applied it across the rest of the code. And that’s what’s so frustrating - it has access to vast knowledge, the entire codebase, and limitless memory - you'd expect better.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But enough of the negatives. I also discovered some strengths.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;It's brilliant at writing documentation from existing code.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;Last week, &lt;a href="https://github.com/monorkin/croatia/"&gt;I started writing a new gem&lt;/a&gt; and it crossed my mind to try letting Claude write the documentation for it. To my surprise, it produced well-worded, concise descriptions, complete with good examples and relevant links - even if a few of the links turned out to be dead.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;It's decent at writing tests.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;I also let it write a few tests, and they turned out surprisingly solid. It covered a lot of edge cases I might’ve missed on a first pass, and the structure was generally clean and readable. Occasionally, it repeated itself more than necessary - but as a starting point, it saved time.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;It's excellent for scaffolding.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;The most enjoyable way to work with an AI agent, for me, was to let it handle the first pass - generate the rough structure. Then I’d come in and rewrite the code the way I like it. That initial scaffold gave me something to react to, and it helped me move faster.&lt;br&gt;&lt;/p&gt;&lt;p&gt;All in all, I learned that I love programming as much as I love solving problems.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Letting the AI drive helped me see that. It made the coding faster - but it also took the joy out of shaping the code, thinking through the domain, and making things beautiful. That’s the part I don’t want to give up.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Working &lt;i&gt;&lt;em&gt;with&lt;/em&gt;&lt;/i&gt; AI, as a partner, is what feels best to me. It can help me move faster, think broader, and stay in flow.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">One of my favorite activities is hiking. I'm fortunate to live close to a mountain where I can go for a hike any time I want - often early in the morning, before work.I love the trek over hills and valleys, through forests and streams, the creaking of trees in the wind, the rustling of leaves,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/45</id>
    <published>2025-05-24T09:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN"/>
    <title>My last PHP app: How I Fell for Ruby</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last week I found a backup of the very last PHP app I ever wrote. The backup was from 2009, but the app itself was written in 2007 - 18 years ago!&lt;br&gt;&lt;/p&gt;&lt;p&gt;Back then I was in my late teens and the world was &lt;i&gt;&lt;em&gt;(or at least felt)&lt;/em&gt;&lt;/i&gt; completely different. Out of nostalgia, I spent some time exploring the source code and even got it to run &lt;i&gt;&lt;em&gt;(for the most part)&lt;/em&gt;&lt;/i&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This reminded me of how different and quirky the web used to be, and how I discovered Ruby.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;What was the app for?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;My friends and I used to play Call of Duty 2 together after school - &lt;i&gt;&lt;em&gt;seems like the world hasn't changed that much after all&lt;/em&gt;&lt;/i&gt; - and we got good at it. So we organized into a team, called a clan, and started playing against other clans for fun.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Every clan we looked up to at the time had its own website where it put up its latest games, demos of those games &lt;i&gt;&lt;em&gt;(a demo is a file that allows you to replay a game and spectate)&lt;/em&gt;&lt;/i&gt;, they had a forum, news, and so on. So we wanted to have our own website, but we were extremely tight on money.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The most that we could spend - all of us together - on a website was around 3€ per month or 35€ per year. To put that into context, 3€ could buy you 2 large pizzas at the time - we were financing this with pocket money.&lt;br&gt;&lt;/p&gt;&lt;p&gt;All hosted website engines were way too expensive for us. We had just enough money to pay for PHP hosting to a local company. For the domain we got a free &lt;a href="https://en.wikipedia.org/wiki/.su"&gt;.su domain&lt;/a&gt; from a sketchy registrar. And now we had to make a website.&lt;br&gt;&lt;/p&gt;&lt;p&gt;At the time I used to write small programs to solve my math homework or just to play around with my computer, and I just learned HTML, so I volunteered to make the website.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Looking back, implementing a website with news, a calendar, file uploads and a comment system was very ambitious for a first project. But I didn't know that at the time so I gave it a try and failed. There were too many moving parts which caused too many bugs, and on top of that the file storage logic that I wrote - &lt;i&gt;&lt;em&gt;because I didn't know that databases existed&lt;/em&gt;&lt;/i&gt; - kept corrupting files forcing me to recreate everything every now and then.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Somehow I came across &lt;a href="http://www.webspell.org/"&gt;webSPELL&lt;/a&gt; - a CMS for clan websites. That solved most of my problems. Now all I had to do was create a template, add and change a few things. All in all I made five websites using PHP and webSPELL - three different iterations of our website, and two websites for other clans.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The last one I made was the third iteration of our website in 2007. Here it is in most of its former glory.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NDc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a8c55ba5b4f1d1fa854c56ef403fd6b3eef2f866" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ3LCJwdXIiOiJibG9iX2lkIn19--3ddc40ea1d44df2d1b7e6231679b34eff61180eb/Pasted%20image%2020250523082554.png" filename="Pasted image 20250523082554.png" filesize="1012972" width="1192" height="983" previewable="true" presentation="gallery" caption="The website, mostly working"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The website, mostly working" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ3LCJwdXIiOiJibG9iX2lkIn19--3ddc40ea1d44df2d1b7e6231679b34eff61180eb/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250523082554.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The website, mostly working
  &lt;/figcaption&gt;
&lt;/figure&gt;It even had a visitor counter - this was the Google Analytics of the time.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NDg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--f0857591f032f28e64deedf00bfd2e3d52012a7d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ4LCJwdXIiOiJibG9iX2lkIn19--9c09e7f8fe2cbe4a6727a4f0e161602f3970fab9/Pasted%20image%2020250523082935.png" filename="Pasted image 20250523082935.png" filesize="35813" width="327" height="335" previewable="true" presentation="gallery" caption="The visitor counter that was in the bottom-right corner"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The visitor counter that was in the bottom-right corner" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ4LCJwdXIiOiJibG9iX2lkIn19--9c09e7f8fe2cbe4a6727a4f0e161602f3970fab9/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250523082935.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The visitor counter that was in the bottom-right corner
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This was written for PHP 4 and I'm running it in a Docker container with PHP 7 so some things - like the news, forum and stats tickers at the top - just broke during the update. I'm honestly amazed that I only had to do a handful of changes to get the app to boot with PHP 7.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Digging through the code made me remember how &lt;b&gt;&lt;strong&gt;different the Web was back then&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;For starters the app rendered &lt;a href="https://en.wikipedia.org/wiki/XHTML"&gt;XHTML&lt;/a&gt; because &lt;a href="https://en.wikipedia.org/wiki/HTML5"&gt;HTML 5&lt;/a&gt; didn't exist yet and &lt;a href="https://en.wikipedia.org/wiki/HTML#HTML4_variations"&gt;HTML 4&lt;/a&gt; was difficult to work with.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"&amp;gt;&lt;br&gt;&amp;lt;html xmlns="http://www.w3.org/1999/xhtml"&amp;gt;&lt;/pre&gt;&lt;p&gt;The encoding was set to &lt;a href="https://en.wikipedia.org/wiki/ISO/IEC_8859-1"&gt;iso-8859-1&lt;/a&gt; to get support for &lt;a href="https://en.wikipedia.org/wiki/Gaj%27s_Latin_alphabet"&gt;Croatian diacritics&lt;/a&gt;. &lt;a href="https://en.wikipedia.org/wiki/UTF-8"&gt;UTF-8&lt;/a&gt; did exist at the time but a lot of software just didn't support (&lt;a href="https://en.wikipedia.org/wiki/Windows_98"&gt;Windows 98&lt;/a&gt; was still very common) so the best thing I could do was to use iso-8859-1 as it would render the characters correctly for most people.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /&amp;gt;&lt;/pre&gt;&lt;p&gt;Then the wild part - this whole site is one giant HTML table with an iframe in it.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NDk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--d941ab0b979ffcc9d9fff809caf6add22c061879" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ5LCJwdXIiOiJibG9iX2lkIn19--40b2112c830ef296b428073565caf5118cfee4b9/Pasted%20image%2020250523082715.png" filename="Pasted image 20250523082715.png" filesize="127587" width="721" height="868" previewable="true" presentation="gallery" caption="DOM of the website"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="DOM of the website" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ5LCJwdXIiOiJibG9iX2lkIn19--40b2112c830ef296b428073565caf5118cfee4b9/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250523082715.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      DOM of the website
  &lt;/figcaption&gt;
&lt;/figure&gt;DIVs did exist, but they were much harder to position than table cells - &lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Flexbox"&gt;flexbox&lt;/a&gt; didn't exist - so they were mostly used as &lt;i&gt;&lt;em&gt;DIV&lt;/em&gt;&lt;/i&gt;iders. And the nice thing about tables is that you can set a fixed width to every cell using the &lt;i&gt;&lt;em&gt;width&lt;/em&gt;&lt;/i&gt; attribute - this was the only way you could do templates back then.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;A template started out as big picture that was sliced into box-shaped sections like a jigsaw puzzle. Then you'd make an HTML table that puts the puzzle back together. You'd make each table cell the exact width and height of your slice and then set the slice image as the background image of that cell. If you had a layout that wouldn't fit into a single table you'd nest HTML tables to get the layout you want.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NTA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--38599bbe794a9fbf669a7c38a45ac2e9282b56e7" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUwLCJwdXIiOiJibG9iX2lkIn19--9a60dd5341469e48d42c61bc9167d1a403321c2a/slicing.png" filename="slicing.png" filesize="30724" width="1095" height="885" previewable="true" presentation="gallery" caption="An example of how slicing worked"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="An example of how slicing worked" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUwLCJwdXIiOiJibG9iX2lkIn19--9a60dd5341469e48d42c61bc9167d1a403321c2a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/slicing.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      An example of how slicing worked
  &lt;/figcaption&gt;
&lt;/figure&gt;Slicing was an art. A big part of the job was to create slices that could be tiled. This saved on bandwidth which, compared to today's 100+ Mbps speeds, were barely able to provide a meager 2 Mbps. This also meant that you'd often have to slice something in such a way that seams and tiling wasn't obvious.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;E.g. the horizontal bars on the top and bottom would get turned into a 1px wide image that would tile horizontally. The vertical bars would be turned in a 1px high tiling image. This also allowed the template to scale to fit any size content and would often reduce a 1MB image down to 1kb or less if you used a GIF.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Another interesting thing - this site was exactly 1011px wide. Most monitors at the time were either 1024px or 1280px wide, so the site would fill most of the screen. Responsiveness just wasn't a thing - the iPhone had just come out and there was literally no way to detect the screen size.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This produces hilarious results on today's monitors. E.g. this is what the site looks like on my ultra-wide.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NTE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--9b573509f61191fb749977dba3f25a3c50f87698" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUxLCJwdXIiOiJibG9iX2lkIn19--101f4a722f8fc606d0df811020b54fd2e0d3255f/Pasted%20image%2020250523134844.png" filename="Pasted image 20250523134844.png" filesize="3445969" width="4992" height="1002" previewable="true" presentation="gallery" caption="What it looks like on a 40&amp;quot; ultra-wide"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="What it looks like on a 40&amp;quot; ultra-wide" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUxLCJwdXIiOiJibG9iX2lkIn19--101f4a722f8fc606d0df811020b54fd2e0d3255f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250523134844.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      What it looks like on a 40" ultra-wide
  &lt;/figcaption&gt;
&lt;/figure&gt;And on my iPhone 14&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NTI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--ed063cd8a89775ace4a01ec1ac814fc58d148489" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUyLCJwdXIiOiJibG9iX2lkIn19--f0031f020441f38c45cfe8f63ea0922fbbc386bf/Pasted%20image%2020250523135013.png" filename="Pasted image 20250523135013.png" filesize="341268" width="424" height="927" previewable="true" presentation="gallery" caption="What it looks like on an iPhone 14"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="What it looks like on an iPhone 14" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjUyLCJwdXIiOiJibG9iX2lkIn19--f0031f020441f38c45cfe8f63ea0922fbbc386bf/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250523135013.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      What it looks like on an iPhone 14
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;I can barely read the text, and I'll need a toothpick to click on a link.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=qObzgUfCl28"&gt;&lt;b&gt;&lt;strong&gt;Ruby, Ruby, Ruby, Ruby&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Over the winter break of 2008, I stumbled across a tweet praising Ruby on Rails as an elegant alternative to PHP. The next day, my mom asked me if I could build a website for her real estate business - perfect timing.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I decided to give Rails a try. webSPELL wasn't a good fit for a real estate website, and I was ready for something new.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Something about Ruby just clicked. The code read like a story. Ruby's syntax felt natural and obvious in a way PHP never had. Suddenly programming wasn't just about hacking things together - it was expressive, joyful, and beautiful.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I remember comparing how I used to join first and last names in PHP:&lt;/p&gt;&lt;pre data-language="php"&gt;implode(" ", [$first_name, $last_name])&lt;/pre&gt;&lt;p&gt;to how it looked in Ruby:&lt;/p&gt;&lt;pre data-language="ruby"&gt;[first_name, last_name].join(" ")&lt;/pre&gt;&lt;p&gt;Ruby felt like I was talking to the data. I'd say "Hey Array, join your elements together with this separator". PHP, by contrast, felt like rummaging through a toolbox hoping the right function was in there somewhere.&lt;/p&gt;&lt;p&gt;Simple database queries became poetic:&lt;/p&gt;&lt;pre data-language="php"&gt;$user=safe_query("SELECT * FROM ".PREFIX."users WHERE first_name = 'John' LIMIT 1");&lt;/pre&gt;&lt;p&gt;vs.&lt;/p&gt;&lt;pre data-language="ruby"&gt;user = User.find_by_first_name("John")&lt;/pre&gt;&lt;p&gt;Rails didn’t just simplify the work - it made me fall in love with writing code. After that, there was no turning back. I decided to turn my programming hobby into my career.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I went to college to learn computer science, protocols, and telecommunications. Then my first job was at a digital agency writing Rails apps to help bootstrap businesses - many of which are still around. And I've been fortunate enough to work with Ruby and with Rails on every job I've had since.&lt;br&gt;&lt;/p&gt;&lt;p&gt;That dusty old PHP app didn't age well, but I'm glad I found it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;It brought back memories of a time when building things felt like magic, which reminded me how I stumbled into Ruby. One tiny decision I made one winter ended up shaping my whole career.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I didn't know it then, but I was writing the first lines of a lifelong love story - one that carried me from Call of Duty matches and sliced-up tables to a profession built on joy, clarity, and code that reads like prose.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last week I found a backup of the very last PHP app I ever wrote. The backup was from 2009, but the app itself was written in 2007 - 18 years ago!

Back then I was in my late teens and the world was (or at least felt) completely different. Out of nostalgia, I spent some time exploring the source...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/44</id>
    <published>2025-05-16T08:00:00Z</published>
    <updated>2026-03-09T14:22:17Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"/>
    <title>Building Twice: A clone of Once</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;When Campfire - the first Once product - came out, a few of us at Ruby Zagreb pitched in to buy a copy.&lt;/p&gt;&lt;p&gt;Prometheus had brought fire from Mount Olympus, and we came to see the blaze. We were finally seeing how the people at 37signals - the birthplace of Rails - build Rails apps. And as a bonus, we could finally ditch our free Slack Channel!&lt;/p&gt;&lt;p&gt;After days of exploring the source code, the time came to set up Campfire. The purchase email included a shell command that looks like this&lt;/p&gt;&lt;pre data-language="bash"&gt;/bin/bash -c "$(curl -fsSL https://auth.once.com/install/1111-1111-1111-1111)&lt;/pre&gt;&lt;p&gt;I ran that, &lt;a href="https://stanko.io/running-campfire-behind-traefik-LrGQM0GE0FVd"&gt;and after solving a few issues&lt;/a&gt;, I had a Docker container running Campfire.&lt;/p&gt;&lt;p&gt;This is a super elegant way to distribute apps! But how did it work? I wasn’t sure so I decided to replicate it and see.&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Figuring out how Once works&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs"&gt;Just like last time&lt;/a&gt;, I started to unravel this ball of yarn by following a thread - the shell script.&lt;/p&gt;&lt;p&gt;Since all it does is execute a script it gets from the Internet I went to see what that script was&lt;/p&gt;&lt;pre data-language="bash"&gt;curl -fsSL https://auth.once.com/install/1111-1111-1111-1111&lt;br&gt;#!/bin/bash&lt;br&gt;set -e&lt;br&gt;&lt;br&gt;echo "Installing the ONCE command..."&lt;br&gt;echo&lt;br&gt;sudo true&lt;br&gt;&lt;br&gt;command_path="https://auth.once.com/install/1111-1111-1111-1111?arch=$(arch)&amp;amp;platform=$(uname)"&lt;br&gt;curl -s $command_path &amp;gt; .once-tmp&lt;br&gt;sudo mv .once-tmp /usr/local/bin/once&lt;br&gt;sudo chmod +x /usr/local/bin/once&lt;br&gt;&lt;br&gt;if [[ "$OSTYPE" == "darwin"* ]]; then&lt;br&gt;  # Don't use sudo on macOS; Docker Desktop is likely to be running as the user&lt;br&gt;  /usr/local/bin/once setup 1111-1111-1111-1111&lt;br&gt;else&lt;br&gt;  sudo /usr/local/bin/once setup 1111-1111-1111-1111&lt;br&gt;fi&lt;/pre&gt;&lt;p&gt;This downloads a binary for your OS and CPU architecture, installs it, and then runs it passing in your license key.&lt;/p&gt;&lt;p&gt;As this was a binary I couldn’t just open it up and see it’s code so I resorted to simple reverse engineering using the &lt;a href="https://www.man7.org/linux/man-pages/man1/strings.1.html"&gt;&lt;i&gt;&lt;em&gt;strings&lt;/em&gt;&lt;/i&gt;&amp;nbsp;command&lt;/a&gt; - it returns all things that look like C strings from a given file.&lt;/p&gt;&lt;pre data-language="plain"&gt;... &lt;br&gt;crypto/tls.masterSecretLabel &lt;br&gt;crypto/tls.extendedMasterSecretLabel &lt;br&gt;crypto/tls.keyExpansionLabel &lt;br&gt;crypto/tls.clientFinishedLabel &lt;br&gt;crypto/tls.serverFinishedLabel &lt;br&gt;mime/multipart..inittask &lt;br&gt;mime/multipart.ErrMessageTooLarge &lt;br&gt;mime/multipart.multipartFiles &lt;br&gt;mime/multipart.multipartMaxParts &lt;br&gt;mime/multipart.emptyParams &lt;br&gt;mime/multipart.multipartMaxHeaders &lt;br&gt;mime/multipart.quoteEscaper &lt;br&gt;net/textproto.errMessageTooLarge &lt;br&gt;net/textproto.colon net/textproto.nl &lt;br&gt;net/textproto.commonHeader &lt;br&gt;net/textproto.commonHeaderOnce &lt;br&gt;net..inittask &lt;br&gt;net.rfc6724policyTable &lt;br&gt;net.confOnce &lt;br&gt;net.confVal &lt;br&gt;net.netdns &lt;br&gt;... &lt;/pre&gt;&lt;p&gt;There were a lot of strings that looked like &lt;a href="https://go.dev/"&gt;Go&lt;/a&gt; packages, functions and methods. I soon found a few references to &lt;a href="https://github.com/spf13/cobra"&gt;cobra&lt;/a&gt; - a Go package for building CLI tools - so I was sure that I was looking at a compiled Go app.&lt;/p&gt;&lt;pre data-language="plain"&gt;... &lt;br&gt;github.com/spf13/cobra.init &lt;br&gt;github.com/spf13/cobra.map.init.0 &lt;br&gt;github.com/spf13/cobra.GetActiveHelpConfig &lt;br&gt;github.com/spf13/cobra.activeHelpEnvVar &lt;br&gt;github.com/spf13/cobra.legacyArgs &lt;br&gt;github.com/spf13/cobra.(*Command).HasSubCommands &lt;br&gt;github.com/spf13/cobra.(*Command).HasParent &lt;br&gt;github.com/spf13/cobra.NoArgs &lt;br&gt;github.com/spf13/cobra.writePreamble &lt;br&gt;... &lt;/pre&gt;&lt;p&gt;This was very fortunate because I’ve started using Go at work lately and I knew that it includes a lot of metadata in its binaries (this can be turned off but isn’t by default). So I looked around and found the whole structure of the app, and all the method signatures.&lt;/p&gt;&lt;pre data-language="plain"&gt;once-cli/ &lt;br&gt;├── cmd/ &lt;br&gt;│ └── once/ &lt;br&gt;│ └── main.go &lt;br&gt;│ &lt;br&gt;└── internal/ &lt;br&gt;    ├── archiver/ &lt;br&gt;    │ ├── compressor.go &lt;br&gt;    │ └── uncompressor.go &lt;br&gt;    │ &lt;br&gt;    ├── cmd/ &lt;br&gt;    │ ├── auto_update.go &lt;br&gt;    │ ├── auto_update_off.go &lt;br&gt;    │ ├── auto_update_on.go &lt;br&gt;    │ ├── data.go &lt;br&gt;    │ ├── data_backup.go &lt;br&gt;    │ ├── data_restore.go &lt;br&gt;    │ ├── password.go &lt;br&gt;    │ ├── password_reset.go &lt;br&gt;    │ ├── root.go &lt;br&gt;    │ ├── setup.go &lt;br&gt;    │ ├── start.go &lt;br&gt;    │ ├── status.go &lt;br&gt;    │ ├── stop.go &lt;br&gt;    │ ├── update.go &lt;br&gt;    │ └── util.go &lt;br&gt;    │ &lt;br&gt;    ├── config/ &lt;br&gt;    │ ├── config.go &lt;br&gt;    │ ├── environment.go &lt;br&gt;    │ └── verifier.go &lt;br&gt;    │ &lt;br&gt;    ├── docker/ &lt;br&gt;    │ └── docker.go &lt;br&gt;    │ &lt;br&gt;    ├── networking/ &lt;br&gt;    │ ├── dns_check.go &lt;br&gt;    │ ├── network_check.go &lt;br&gt;    │ └── ssl_traffic_check.go &lt;br&gt;    │ &lt;br&gt;    └── tui/ &lt;br&gt;      ├── backup/ &lt;br&gt;      │ └── backup.go &lt;br&gt;      ├── components/ &lt;br&gt;      │ └── task_list.go &lt;br&gt;      ├── question/ &lt;br&gt;      │ └── question.go &lt;br&gt;      ├── restore/ &lt;br&gt;      │ └── restore.go &lt;br&gt;      ├── runner/ &lt;br&gt;      │ └── runner.go &lt;br&gt;      ├── setup/ &lt;br&gt;      │ ├── installer.go &lt;br&gt;      │ ├── registration.go &lt;br&gt;      │ └── setup.go &lt;br&gt;      └── util.go &lt;/pre&gt;&lt;p&gt;And I found three URLs&lt;/p&gt;&lt;pre data-language="plain"&gt;https://auth.once.com/install/ &lt;br&gt;https://auth.once.com/verify/%d &lt;br&gt;registry.once.com &lt;/pre&gt;&lt;p&gt;The first one was the same as the download URL, the second one obviously verifies something but I didn’t know what. My guess is for the password reset functionality (look at &lt;i&gt;&lt;em&gt;once-cli/internal/cmd/password_reset.go&lt;/em&gt;&lt;/i&gt;) because it interpolates a number at the end (&lt;a href="https://pkg.go.dev/fmt"&gt;that's the &lt;i&gt;&lt;em&gt;%d&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;). The third one looks like a private Docker registry.&lt;/p&gt;&lt;p&gt;Can I login to the Docker registry?&lt;/p&gt;&lt;p&gt;After set up, Once creates a config file where it stores how you configured it.&lt;/p&gt;&lt;pre data-language="json"&gt;// contents of /root/.config/once/config.json&lt;br&gt;{&lt;br&gt;  "token": "1111-1111-1111-1111",&lt;br&gt;  "product": "campfire",&lt;br&gt;  "product_name": "Campfire",&lt;br&gt;  "email_address": "stanko@example.com",&lt;br&gt;  "ssl_domain": "chat.rubyzg.org",&lt;br&gt;  "validation_token": "PHONY_VALIDATION_KEY",&lt;br&gt;  "secret_key_base": "PHONY_SECRET_KEY_BASE",&lt;br&gt;  "vapid_private_key": "PHONY_PRIVATE_KEY",&lt;br&gt;  "vapid_public_key": "PHONY_PUBLIC_KEY",&lt;br&gt;  "storage_location": "/var/once/campfire",&lt;br&gt;  "cron_hour": 2,&lt;br&gt;  "once_binary_etag": ""&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Notice that it stores the license key and my email. Also notice that it calls the license key "token". My guess is that it uses the license key as an auth token with the registry to pull updates. That's a common way to do token auth with Docker Registries. Let's try that.&lt;/p&gt;&lt;pre data-language="bash"&gt;docker login \&lt;br&gt;  --username 'stanko@example.com' \&lt;br&gt;  --password '1111-1111-1111-1111' \&lt;br&gt;  'https://registry.once.com'&lt;br&gt;&lt;br&gt;Login Succeeded&lt;/pre&gt;&lt;p&gt;Bingo!&lt;/p&gt;&lt;p&gt;Now, where did it get my email, the product, and product name from? These weren't asked during set up. I'm guessing that the install URL is in the binary because it can respond with &lt;i&gt;&lt;em&gt;HTML / text&lt;/em&gt;&lt;/i&gt; for the install script but it can probably also respond to &lt;i&gt;&lt;em&gt;JSON&lt;/em&gt;&lt;/i&gt;. Let's try that&lt;/p&gt;&lt;pre data-language="bash"&gt;curl https://auth.once.com/install/1111-1111-1111-1111.json | jq&lt;br&gt;{&lt;br&gt;  "token": "1111-1111-1111-1111",&lt;br&gt;  "product": "campfire",&lt;br&gt;  "product_name": "Campfire",&lt;br&gt;  "email_address": "stanko@example.com",&lt;br&gt;  "ssl_domain": null,&lt;br&gt;  "validation_token": "&amp;lt;some-base64-string&amp;gt;"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Two for two!&lt;/p&gt;&lt;p&gt;I was curious about the validation token as it was obviously a Base64 string, but decoding it returned complete gibberish - it doesn't even look like unicode - so I assume that it's either completely random or an encrypted payload. Maybe that's what the verify endpoint is for?&lt;/p&gt;&lt;p&gt;My guess is that the token is used to update the "ssl_domain" field with the Once auth server after the install completes or during the DNS check. That would mean that the install endpoint probably also responds to a POST request.&lt;/p&gt;&lt;p&gt;Anyway, I now had a good understanding of how Once works.&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;How Once works&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;Once consists of three main parts:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;The Auth server&lt;/li&gt;&lt;li&gt;The private Docker Registry&lt;/li&gt;&lt;li&gt;The CLI app&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MzQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--20929bbc6b9c41c9272a527a25daa2ff7388f0cd" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM0LCJwdXIiOiJibG9iX2lkIn19--b2c61ea6768c3bec3e5850dd5f850dd528936b3e/once_overview.png" filename="once_overview.png" filesize="209974" width="911" height="1058" previewable="true" presentation="gallery" caption="An overview of Once"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="An overview of Once" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM0LCJwdXIiOiJibG9iX2lkIn19--b2c61ea6768c3bec3e5850dd5f850dd528936b3e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/once_overview.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      An overview of Once
  &lt;/figcaption&gt;
&lt;/figure&gt;T&lt;i&gt;&lt;em&gt;he Auth server&lt;/em&gt;&lt;/i&gt; handles token validation for the registry, distribution of the CLI binary, license key inspection, and probably &lt;a href="https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create"&gt;the order created webhook from Shopify&lt;/a&gt;.&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;The CLI&lt;/em&gt;&lt;/i&gt; handles Docker installation and configuration on the client, pulling and running of product Docker images, product container management, automatic-updates, backups, restores, and a few other things.&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;The private Docker Registry&lt;/em&gt;&lt;/i&gt; is, well, just a Docker Registry... It stores and distributes Docker images.&lt;/p&gt;&lt;p&gt;The license key you get is used as an access token to the registry. With it the CLI can pull images from the registry. That token probably grants access to a single repository in the registry - just for the product you purchased.&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Building Twice&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;Now the fun part, making a clone of Once.&lt;/p&gt;&lt;p&gt;I decided to call this project "Twice" as a joke on the fact that it's a clone of Once - it's a second implementation. &lt;a href="https://github.com/monorkin/twice"&gt;The source code is available on GitHub&lt;/a&gt; if you want to explore it, build, modify, or run Twice for yourself.&lt;/p&gt;&lt;p&gt;My main motivation is to learn more about how Once is set up, but a nice side effect is that I'll get a distribution system out of it - in the case that I decide to make an app and sell it. With that in mind, my primary goal is to implement just the setup CLI command. I'll work on everything else later.&lt;/p&gt;&lt;p&gt;&lt;a href="https://stanko.io/building-simpl-77CAzym51p5a"&gt;Just like with Simpliki&lt;/a&gt;, I'll start from the center and work myself out. In the case of Twice the center is the CLI app - specifically the setup command.&lt;/p&gt;&lt;p&gt;As I've been working with Go at work lately, I felt confident in using it for the CLI. This would be a nice exercise to sharpen my Go skills. Otherwise I'd probably go with Rust as I have more experience with it, or I'd give &lt;a href="https://github.com/tamatebako/tebako"&gt;Tebako&lt;/a&gt; a try - a new way to package Ruby apps into a single binary.&lt;/p&gt;&lt;p&gt;So I started a new Go project, added cobra to it and &lt;a href="https://cobra.dev/#getting-started"&gt;followed the getting started guide&lt;/a&gt; to create a CLI app with a &lt;i&gt;&lt;em&gt;setup&lt;/em&gt;&lt;/i&gt; command that accepts one argument - &lt;i&gt;&lt;em&gt;the license key&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;pre data-language="plain"&gt;go run cmd/twice/main.go setup --help&lt;br&gt;Install all the necessary dependencies to run a product and then installs the product associated with the given license key&lt;br&gt;&lt;br&gt;Usage:&lt;br&gt;  twice setup &amp;lt;license-key&amp;gt; [flags]&lt;br&gt;&lt;br&gt;Flags:&lt;br&gt;  -h, --help   help for setup&lt;/pre&gt;&lt;p&gt;At this point I got lost in the weeds. Once has this nice &lt;a href="https://en.wikipedia.org/wiki/Text-based_user_interface"&gt;TUI (terminal user interface)&lt;/a&gt; that walks you through the setup process so, naturally, I wanted to make a nice TUI too.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MzU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--88ec3a444312d6e390c2153ccd5fdcb28fe8d4a4" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM1LCJwdXIiOiJibG9iX2lkIn19--026f54a658ac173d704599c01bb6bd46a15f43f3/twice_tui_demo.mp4" filename="twice_tui_demo.mp4" filesize="60692" width="1890.0" height="1011.9999999999999" previewable="true" presentation="gallery" caption="Twice TUI"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM1LCJwdXIiOiJibG9iX2lkIn19--026f54a658ac173d704599c01bb6bd46a15f43f3/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/twice_tui_demo.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM1LCJwdXIiOiJibG9iX2lkIn19--026f54a658ac173d704599c01bb6bd46a15f43f3/twice_tui_demo.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Twice TUI
  &lt;/figcaption&gt;
&lt;/figure&gt;I spent a couple of hours working on it, I got quite far, but in the end I realized the TUI didn't matter. It was just slowing me down, so I ditched it and replaced it with good old &lt;i&gt;&lt;em&gt;puts&lt;/em&gt;&lt;/i&gt;.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MzY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--cfcb804cbd29f1a3df7a8475d14f00ff50c638fa" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM2LCJwdXIiOiJibG9iX2lkIn19--69ece7f0082ddb024d715bce3b3e56536df86115/twice_cli_demo.mp4" filename="twice_cli_demo.mp4" filesize="97588" width="1892.0" height="1011.9999999999999" previewable="true" presentation="gallery" caption="Twice CLI"&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM2LCJwdXIiOiJibG9iX2lkIn19--69ece7f0082ddb024d715bce3b3e56536df86115/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/twice_cli_demo.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM2LCJwdXIiOiJibG9iX2lkIn19--69ece7f0082ddb024d715bce3b3e56536df86115/twice_cli_demo.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Twice CLI
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This was waaaaay quicker to implement and allowed me to focus on the actual logic - like &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/check.go"&gt;figuring out if docker was installed, is it running&lt;/a&gt;, &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/api/license.go"&gt;writing an API client&lt;/a&gt;, &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/pull.go"&gt;interacting with Docker&lt;/a&gt;, etc. - instead of rendering a fancy UI.&lt;p&gt;&lt;/p&gt;&lt;p&gt;I like the version with the TUI more - it feels refined - and I think I'll revisit it once I'm more comfortable with Go and &lt;a href="https://github.com/charmbracelet/bubbletea"&gt;Bubble tea&lt;/a&gt; (the library that renders the TUI).&lt;/p&gt;&lt;p&gt;As I was building the CLI I started the Auth app and mocked a few endpoints just to get the setup command done. I didn't implement any UI and relied heavily on &lt;i&gt;&lt;em&gt;"bin/rails db:fixtures:load"&lt;/em&gt;&lt;/i&gt; to give me some data to work with. These endpoints were plain Rails controllers that rendered JSON.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Mzc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--173765cdeb4628559372041ee17174df0f886d9d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM3LCJwdXIiOiJibG9iX2lkIn19--1e7ffb63d79455c10927580d81948baca94d560d/original_models.png" filename="original_models.png" filesize="195057" width="1435" height="998" previewable="true" presentation="gallery" caption="The models I had for Twice"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The models I had for Twice" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM3LCJwdXIiOiJibG9iX2lkIn19--1e7ffb63d79455c10927580d81948baca94d560d/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/original_models.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The models I had for Twice
  &lt;/figcaption&gt;
&lt;/figure&gt;With most of the CLI out of the way, and with some of the Auth app done I turned my attention to the Docker Registry.&lt;p&gt;&lt;/p&gt;&lt;p&gt;There are a few Docker Registries out there:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;a href="https://distribution.github.io/distribution/"&gt;CNCF Registry&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/goharbor/harbor"&gt;Harbor&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/SUSE/Portus"&gt;Portus&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;I liked the idea of having a Registry that's written in Rails so I tried Portus, but I couldn't get it to run locally - there are multiple known issues and the project was archived 2 years ago.&lt;/p&gt;&lt;p&gt;Harbor is a conglomeration of micro services. When I saw that I had to run 4 or more containers just to get the thing going I gave up.&lt;/p&gt;&lt;p&gt;This left me with Registry. It has everything I need - access control via token auth to a configurable auth server. It has no UI or API, but this is a plus as I don't have to think about customers trying to login to the registry UI or interacting with it other than pulling images. And I can make the system stateless, thus less error-prone.&lt;/p&gt;&lt;p&gt;&lt;br&gt;All I had to do to get the registry working is to generate a self-signed certificate&lt;/p&gt;&lt;pre data-language="bash"&gt;openssl req -x509 -newkey rsa:4096 -nodes \&lt;br&gt;    -keyout ./auth.key \&lt;br&gt;    -out ./auth.crt \&lt;br&gt;    -days 365 \&lt;br&gt;    -subj "/CN=registry-auth" \&lt;br&gt;    -addext "basicConstraints=critical,CA:true" \&lt;br&gt;    -addext "keyUsage=keyCertSign,digitalSignature" \&lt;br&gt;    -addext "subjectAltName=DNS:registry-auth"&lt;/pre&gt;&lt;p&gt;And set a few environment variables that configure it to use token auth, tell it to use my auth server, and to verify the auth tokens using the certificate I just generated&lt;/p&gt;&lt;pre data-language="bash"&gt;# Use token auth&lt;br&gt;REGISTRY_AUTH=token&lt;br&gt;# Use `http://localhost:3000/registry/auth` as the auth server&lt;br&gt;REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth&lt;br&gt;# This isn't too important&lt;br&gt;REGISTRY_AUTH_TOKEN_SERVICE=registry&lt;br&gt;# This has to match what you return from the auth server and what's in the certificate&lt;br&gt;REGISTRY_AUTH_TOKEN_ISSUER=registry-auth&lt;br&gt;# This specifies which certificate to use to verify tokens&lt;br&gt;REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pem&lt;/pre&gt;&lt;p&gt;That's it. Here is a Docker compose file for it&lt;/p&gt;&lt;pre data-language="yaml"&gt;services:&lt;br&gt;  registry:&lt;br&gt;    image: registry:2&lt;br&gt;    # On Linux&lt;br&gt;    network_mode: host&lt;br&gt;    # On Mac&lt;br&gt;    # ports:&lt;br&gt;    #   - 5000:5000&lt;br&gt;    environment:&lt;br&gt;      - REGISTRY_AUTH=token&lt;br&gt;      - REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth&lt;br&gt;      - REGISTRY_AUTH_TOKEN_SERVICE=registry&lt;br&gt;      - REGISTRY_AUTH_TOKEN_ISSUER=registry-auth&lt;br&gt;      - REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pem&lt;br&gt;      - REGISTRY_STORAGE_DELETE_ENABLED=true&lt;br&gt;    volumes:&lt;br&gt;      - ./registry/tmp/registry_data:/var/lib/registry&lt;br&gt;      - ./registry/certs:/certs:ro&lt;/pre&gt;&lt;p&gt;Now the only thing left was to finish the Auth server.&lt;/p&gt;&lt;p&gt;First, I tackled registry token auth. This requires a single endpoint that responds to both POST and GET requests&lt;/p&gt;&lt;pre data-language="ruby"&gt;match "/registry/auth", to: "registry#auth", as: :registry_auth, via: %i[ get post ]&lt;/pre&gt;&lt;p&gt;The endpoint receives the users email and token via HTTP basic auth and responds with a JSON payload that looks like this&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "token": "signed.jwt.token",&lt;br&gt;  "expires_in": 300,&lt;br&gt;  "issued_at": "2025-05-15T16:55:23Z"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;The token from the response is a JWT that contains a list of access permissions - these define which actions the user can perform.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "iss": "registry-auth", // Has to match the value set in REGISTRY_AUTH_TOKEN_ISSUER&lt;br&gt;  "sub": "stankoexample.com", // Username of the person logging in&lt;br&gt;  "aud": "registry", // Has to match REGISTRY_AUTH_TOKEN_SERVICE&lt;br&gt;  "exp": 1747329069,&lt;br&gt;  "nbf": 1747328784,&lt;br&gt;  "iat": 1747328784,&lt;br&gt;  "jti": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed",&lt;br&gt;  "access": [&lt;br&gt;	{&lt;br&gt;	  "type": "repository",&lt;br&gt;	  "name": "campfire",&lt;br&gt;	  "actions": ["pull"]&lt;br&gt;	}&lt;br&gt;  ]&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;The tricky part was to sign the token with the certificate such that the registry would accept it as valid. This took me some time to figure out. To get it to work I had to set two additional JWT &lt;a href="https://curity.io/resources/learn/self-contained-jwts/"&gt;headers called KID and X5C&lt;/a&gt;, which tell the registry exactly which certificate it should validate the token with. Both headers are derived from the signing certificate itself.&lt;/p&gt;&lt;pre data-language="ruby"&gt;KID = OpenSSL::Digest&lt;br&gt;  .new("SHA256")&lt;br&gt;  .update(PRIVATE_KEY.public_key.to_der)&lt;br&gt;  .hexdigest&lt;br&gt;  .upcase&lt;br&gt;  .scan(/.{1,2}/)&lt;br&gt;  .join(":")&lt;br&gt;X5C = Base64.strict_encode64(PUBLIC_KEY.to_der)&lt;/pre&gt;&lt;p&gt;Putting it all together, the controller looks like this&lt;/p&gt;&lt;pre data-language="ruby"&gt;def auth&lt;br&gt;  authenticate_or_request_with_http_basic do |email, license_key|&lt;br&gt;    user = User.find_by(email_address: email)&lt;br&gt;    deny_access and return if user.blank?&lt;br&gt;&lt;br&gt;    license = user.licenses.find_by_key(license_key)&lt;br&gt;&lt;br&gt;    token = user.generate_registry_access_token_to_product(license.product, service: params[:service])&lt;br&gt;    deny_access and return if token.blank?&lt;br&gt;&lt;br&gt;    payload = {&lt;br&gt;      token: token.to_s,&lt;br&gt;      expires_in: token.duration.to_i,&lt;br&gt;      issued_at: token.issued_at.iso8601&lt;br&gt;    }&lt;br&gt;&lt;br&gt;    render(json: payload, status: :ok)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Notice that &lt;a href="https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/auth/app/models/docker_registry/token.rb"&gt;the token is an object&lt;/a&gt;. This provides a nice domain language (&lt;a href="https://en.wikipedia.org/wiki/Domain-driven_design"&gt;DDD&lt;/a&gt; domain language) that makes it easier to work with, and reason about, the token. If I want the duration or the issued at time I can ask the token for it; if I want to turn it into a string I can do that too.&lt;/p&gt;&lt;p&gt;At this point I noticed that I didn't implement a way to push images to the registry. To fix that I introduced a new type of user - the Developer. &lt;a href="https://guides.rubyonrails.org/association_basics.html#single-table-inheritance-sti"&gt;STI&lt;/a&gt; made the most sense for this, so I converted the customers table to a users table, added a type column and a few extra columns.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Mzg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--091e0cc6f780c2b9a146255668911e833200253d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM4LCJwdXIiOiJibG9iX2lkIn19--139d9ffdd2835c43c774c366a68d934c42c0e286/final_models.png" filename="final_models.png" filesize="239018" width="1422" height="1120" previewable="true" presentation="gallery" caption="The models in Twice after adding developers"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The models in Twice after adding developers" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM4LCJwdXIiOiJibG9iX2lkIn19--139d9ffdd2835c43c774c366a68d934c42c0e286/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/final_models.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The models in Twice after adding developers
  &lt;/figcaption&gt;
&lt;/figure&gt;Unlike Customers, Developers login with a password tied to their account and they get full access to the registry.&lt;p&gt;&lt;/p&gt;&lt;p&gt;Second, the install endpoint. This was straight forward&lt;/p&gt;&lt;pre data-language="ruby"&gt;# config.routes.rb&lt;br&gt;get "/install/:license_key", to: "install#install", as: :install, defaults: { format: :text }&lt;br&gt;&lt;br&gt;# app/controllers/install_controller.rb&lt;br&gt;def install&lt;br&gt;  respond_to do |format|&lt;br&gt;    format.text&lt;br&gt;    format.json&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;And then I just had to create two separate view files. One for the script in &lt;i&gt;&lt;em&gt;app/views/install/install.text.erb&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;pre data-language="bash"&gt;#!/usr/bin/env bash&lt;br&gt;set -e&lt;br&gt;&lt;br&gt;echo "Installing the TWICE command..."&lt;br&gt;echo&lt;br&gt;&lt;br&gt;sudo true&lt;br&gt;&lt;br&gt;command_path="&amp;lt;%= install_download_url %&amp;gt;?arch=$(uname -m)&amp;amp;platform=$(uname)"&lt;br&gt;curl -s "$command_path" -o .twice_tmp&lt;br&gt;sudo mv .twice_tmp /usr/local/bin/twice&lt;br&gt;sudo chmod +x /usr/local/bin/twice&lt;br&gt;&lt;br&gt;if [[ "$OSTYPE" == "darwin"* ]]; then&lt;br&gt;  # Don't use sudo on macOS; Docker Desktop is likely to be running as the user&lt;br&gt;  /usr/local/bin/twice setup &amp;lt;%= @license.key %&amp;gt;&lt;br&gt;else&lt;br&gt;  sudo /usr/local/bin/twice setup &amp;lt;%= @license.key %&amp;gt;&lt;br&gt;fi&lt;/pre&gt;&lt;p&gt;And another in &lt;i&gt;&lt;em&gt;app/views/install/install.json.jbuilder&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;json.key @license.key&lt;br&gt;&lt;br&gt;json.owner do&lt;br&gt;  json.(@license.owner, :id, :email_address)&lt;br&gt;end&lt;br&gt;&lt;br&gt;json.product do&lt;br&gt;  json.(@license.product, :id, :name, :repository)&lt;br&gt;  json.registry ENV.fetch("REGISTRY_URL", "localhost:5000")&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Notice that I strayed a bit from 37signals' implementation when it comes to downloading the binary. I decided to separate that out into another controller action because the install endpoint already does too many things in my opinion, and adding file sending to that seems like way too much responsibility for one endpoint.&lt;/p&gt;&lt;p&gt;I also made the decision that the auth server would directly send the file to the client instead of e.g. redirecting to something like S3. This makes it easier to self-host twice, and due to a trick it’s no less performant.&lt;/p&gt;&lt;p&gt;Here is the download action&lt;/p&gt;&lt;pre data-language="ruby"&gt;def download&lt;br&gt;  platform = sanitize_file_name_part(params[:platform])&lt;br&gt;  arch = sanitize_file_name_part(params[:arch])&lt;br&gt;&lt;br&gt;  filename = ["twice", platform, arch].compact.join("-")&lt;br&gt;  file_path = Pathname.new(Rails.root.join("storage", filename))&lt;br&gt;&lt;br&gt;  if file_path.exist?&lt;br&gt;    send_file file_path, type: "application/octet-stream", filename: filename&lt;br&gt;  else&lt;br&gt;    head :not_found&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;It assumes that the requested binary will be called &lt;i&gt;&lt;em&gt;"twice-#{platform}-#{arch}"&lt;/em&gt;&lt;/i&gt;, then it goes and checks if such a file exists in the &lt;i&gt;&lt;em&gt;storage&lt;/em&gt;&lt;/i&gt; directory, and if it does it sends it.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82Mzk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--4e21d9deb5871fbaf4e20a22d3e18911f8d957b5" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM5LCJwdXIiOiJibG9iX2lkIn19--3e38d1809d2b7918e8685f3c84f913ac5c373d8a/twice_cli_download_demo.png" filename="twice_cli_download_demo.png" filesize="71147" width="888" height="378" previewable="true" presentation="gallery" caption="The download endpoint in action"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The download endpoint in action" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjM5LCJwdXIiOiJibG9iX2lkIn19--3e38d1809d2b7918e8685f3c84f913ac5c373d8a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/twice_cli_download_demo.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The download endpoint in action
  &lt;/figcaption&gt;
&lt;/figure&gt;In production this won't actually stream the file from Ruby, &lt;a href="https://api.rubyonrails.org/classes/ActionController/DataStreaming.html#method-i-send_file"&gt;instead it sets a special header&lt;/a&gt; that tells &lt;a href="https://github.com/basecamp/thruster"&gt;Thruster&lt;/a&gt; to send the file which makes things way more efficient.&lt;p&gt;&lt;/p&gt;&lt;p&gt;Third, I added authentication using the new auth generator in Rails 8, and a UI for managing customers, developers, products and licenses.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NDA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--f0d860e313790058c8d38788f05668c12cc8ac4e" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQwLCJwdXIiOiJibG9iX2lkIn19--d525600cb92aad462b8c44bcdc5cc8fdbc36dcd8/auth_ui_demo.mp4" filename="auth_ui_demo.mp4" filesize="1198133" width="1194.0" height="984.0" previewable="true" presentation="gallery" caption="Demo of the Auth app UI"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQwLCJwdXIiOiJibG9iX2lkIn19--d525600cb92aad462b8c44bcdc5cc8fdbc36dcd8/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/auth_ui_demo.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQwLCJwdXIiOiJibG9iX2lkIn19--d525600cb92aad462b8c44bcdc5cc8fdbc36dcd8/auth_ui_demo.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the Auth app UI
  &lt;/figcaption&gt;
&lt;/figure&gt;And that was it. Twice was done. Here is the demo&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82NDY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--6c4405d96e136273130b55f8b98cf37f189f30f6" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ2LCJwdXIiOiJibG9iX2lkIn19--03fcbf6a985b4d8ad9f25b0608b2f6383e8dc55f/twice_demo.mp4" filename="twice_demo.mp4" filesize="453178" width="1892.0" height="995.9999999999999" previewable="true" presentation="gallery" caption="Demo of Twice installing an app"&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ2LCJwdXIiOiJibG9iX2lkIn19--03fcbf6a985b4d8ad9f25b0608b2f6383e8dc55f/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/twice_demo.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjQ2LCJwdXIiOiJibG9iX2lkIn19--03fcbf6a985b4d8ad9f25b0608b2f6383e8dc55f/twice_demo.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of Twice installing an app
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;b&gt;&lt;strong&gt;Some loose thoughts&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;When writing about building Simpliki I left out an important part - what I intentionally didn't do.&lt;/p&gt;&lt;p&gt;For Twice, I intentionally didn't implement domain name tracking as I think that it's not important. It can't prevent people from running the docker container how and where they want. It would add complexity and I don't see the benefit of it, so I ditched it.&lt;/p&gt;&lt;p&gt;I do see the utility in all the backup and management commands that the Once CLI has, which that I didn't implement in Twice. I intend to return to that at some point. But they aren't important for distributing apps so I skipped them.&lt;/p&gt;&lt;p&gt;Twice, currently, has the same problem as Once - it doesn't play well with other apps or &lt;a href="https://kamal-deploy.org/"&gt;Kamal&lt;/a&gt;. This isn't a problem if you rent a VPS specifically to run a Once app, but if you have a large bare metal machine like me then this is cumbersome. I'd like to change it so that it deploys &lt;a href="https://github.com/basecamp/kamal-proxy"&gt;kamal-proxy&lt;/a&gt; and then proxies requests to different apps through it. This would allow it to run multiple apps on the same server.&lt;/p&gt;&lt;p&gt;That's also why the Twice CLI doesn't keep a config file - I'd like to make it so that you can run multiple apps, so the config has to support that. This was extra work that I think isn't important for now. And I'd like to be able to install an app from different Twice instances on the same server. Something like&lt;/p&gt;&lt;pre data-language="bash"&gt;twice setup 1111-1111-1111-1111@auth.foo.com&lt;br&gt;twice setup 2222-2222-2222-2222@auth.bar.com&lt;/pre&gt;&lt;p&gt;This would make it more versatile - but, again, that's way beyond the scope of creating a clone to learn how Once works.&lt;br&gt;&lt;br&gt;I liked working with Go. After years of using Rust, having a garbage collector and not having to think about results, unwrapping, lifetimes or borrowing was a breath of fresh air. Though, the way it declares modules still doesn't sit right with me. If you didn't look at the source code, each directory is its own module, and all files in the directory get essentially concatenated together into that module at compile time. It works, and I can't point out any problems with that, but it feels weird.&lt;/p&gt;&lt;p&gt;The folks at 37signals made a really elegant distribution system for their apps. I had a lot of fun figuring out how it works and I learned a lot along the way.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">When Campfire - the first Once product - came out, a few of us at Ruby Zagreb pitched in to buy a copy.

Prometheus had brought fire from Mount Olympus, and we came to see the blaze. We were finally seeing how the people at 37signals - the birthplace of Rails - build Rails apps. And as a bonus,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/43</id>
    <published>2025-05-06T09:00:00Z</published>
    <updated>2026-03-09T09:32:25Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/adding-mcp-to-a-rails-app-NG9zkX3dyPq1"/>
    <title>Adding MCP to a Rails app</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last week we had an AI hackathon at work during which we added an MCP server to our Rails app. This was much easier than I expected, the result was both impressive and scary, and I learned a few things about MCP that I didn't expect.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MDY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--fa9d498a6babcf3d6f12d92acdb1d6aee5497059" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA2LCJwdXIiOiJibG9iX2lkIn19--83fe661531b61891dc6514bac87dd6d9e2b79482/Screencast%20From%202025-05-06%2010-58-48%20(1)%20-%20web.mp4" filename="Screencast From 2025-05-06 10-58-48 (1) - web.mp4" filesize="400815" width="638.0" height="664.0000000000001" previewable="true" presentation="gallery" caption="Demo of Claude using our app via MCP to solve a support request"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA2LCJwdXIiOiJibG9iX2lkIn19--83fe661531b61891dc6514bac87dd6d9e2b79482/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-05-06%2010-58-48%20(1)%20-%20web.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA2LCJwdXIiOiJibG9iX2lkIn19--83fe661531b61891dc6514bac87dd6d9e2b79482/Screencast%20From%202025-05-06%2010-58-48%20(1)%20-%20web.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of Claude using our app via MCP to solve a support request
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Goal&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;My team wanted to help our support team by automating some laborious tasks, like cross referencing data, by offloading the bulk of the work to an AI agent. This would make support’s work easier, responses quicker, and in some cases it would free up my team too - an all around win.&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;To allow the AI to interact with our app - like a person would - we decided to add MCP to it.&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;What is MCP?&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;MCP, or Model Context Protocol, is a protocol through which LLMs can interact with different tools and resources to accomplish tasks.&lt;/p&gt;&lt;p&gt;&lt;a href="https://modelcontextprotocol.io/introduction"&gt;The protocol specifics are fairly dry so I'll gloss over them&lt;/a&gt;. What you need to know is that in MCP there exists a server and a client.&lt;/p&gt;&lt;p&gt;A server provides tools, prompts, and resources (I'll focus only on tools). While a client connects to one, or multiple, servers and exposes their tools to an LLM. The client then makes API requests to the Servers to use tools when the LLM tells it to.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MDc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--994b99b31f2fa553d776974af31daaf2cd94d93e" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA3LCJwdXIiOiJibG9iX2lkIn19--2d924c9d7959664deaebdc45c6215ab01e76f018/Quick%20sheets%20-%20page%2033.png" filename="Quick sheets - page 33.png" filesize="227406" width="1305" height="1220" previewable="true" presentation="gallery" caption="Overview of how the MCP client and server interact"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Overview of how the MCP client and server interact" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA3LCJwdXIiOiJibG9iX2lkIn19--2d924c9d7959664deaebdc45c6215ab01e76f018/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Quick%20sheets%20-%20page%2033.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Overview of how the MCP client and server interact
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;i&gt;&lt;em&gt;In essence - the Client uses the LLM as a brain to plan out how to solve a problem and the Servers as tools to implement the solution.&lt;/em&gt;&lt;/i&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;A tool can be anything from a query, like searching for a user, to a mutation, like creating a coupon code - anything the server is allowed to do on behalf of the LLM.&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;E.g. You could ask a client to &lt;i&gt;&lt;em&gt;"Find a file called meeting_notes.txt"&lt;/em&gt;&lt;/i&gt;, it would then use the LLM to interpret what that means and plan out how it would do that with the tools it has available. It gets a list of tools from the MCP servers it's connected to.&lt;/p&gt;&lt;p&gt;The LLM might respond with something like &lt;i&gt;&lt;em&gt;"{action: 'tool_call', tool: 'find_file', arguments: ['meeting_notes.txt']}"&lt;/em&gt;&lt;/i&gt; and the client would go and make a request to an MCP server that exposes a tool called find_file.&lt;br&gt;&lt;br&gt;The server then responds with something like &lt;i&gt;&lt;em&gt;"{path: '/home/user/meeting_notes.txt', contents: '...'}"&lt;/em&gt;&lt;/i&gt; and the client passes that on to the LLM which then decides what to do with that information.&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;MCP and Rails&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;I used the &lt;a href="https://github.com/yjacquin/fast-mcp"&gt;fast-mcp gem&lt;/a&gt; to add MCP support to our app.&lt;/p&gt;&lt;pre data-language="shell"&gt;bundle add fast-mcp&lt;br&gt;bin/rails generate fast_mcp:install&lt;/pre&gt;&lt;p&gt;This gave me two new directories - &lt;i&gt;&lt;em&gt;app/tools&lt;/em&gt;&lt;/i&gt; and &lt;i&gt;&lt;em&gt;app/resources&lt;/em&gt;&lt;/i&gt; - and a class name conflict.&lt;/p&gt;&lt;p&gt;The resources directory contains an &lt;i&gt;&lt;em&gt;ApplicationResource&lt;/em&gt;&lt;/i&gt; class, but our app already has a different &lt;i&gt;&lt;em&gt;ApplicationResource&lt;/em&gt;&lt;/i&gt; class in &lt;i&gt;&lt;em&gt;app/models&lt;/em&gt;&lt;/i&gt; that we use to represent resources from 3rd party REST APIs.&lt;/p&gt;&lt;p&gt;To solve the conflict I decided to namespace everything MCP-related under an &lt;i&gt;&lt;em&gt;MCP&lt;/em&gt;&lt;/i&gt; namespace.&lt;/p&gt;&lt;p&gt;First I moved the two new directories into an &lt;i&gt;&lt;em&gt;mcp&lt;/em&gt;&lt;/i&gt; directory&lt;/p&gt;&lt;pre data-language="shell"&gt;mkdir app/mcp&lt;br&gt;mv app/tools app/mcp/tools&lt;br&gt;mv app/resources app/mcp/resources&lt;/pre&gt;&lt;p&gt;But now the classes were namespaced with &lt;i&gt;&lt;em&gt;Tools&lt;/em&gt;&lt;/i&gt; and &lt;i&gt;&lt;em&gt;Resources&lt;/em&gt;&lt;/i&gt; instead of with &lt;i&gt;&lt;em&gt;MCP&lt;/em&gt;&lt;/i&gt; because Zeitwerk treats each directory in app/ as a root directory. To reconfigure Zeitwerk I added the following to &lt;i&gt;&lt;em&gt;config/initializers/zeitwerk.rb&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;# config/initializers/zeitwerk.rb&lt;br&gt;&lt;br&gt;module MCP; end&lt;br&gt;&lt;br&gt;Rails.autoloaders.main.tap do |autoloader|&lt;br&gt;  Dir.glob(Rails.root.join("app/mcp/*")).each do |file|&lt;br&gt;    next unless File.directory?(file)&lt;br&gt;&lt;br&gt;    autoloader.push_dir(file, namespace: MCP)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;That tells Zeitwerk to treat every directory in &lt;i&gt;&lt;em&gt;app/mcp&lt;/em&gt;&lt;/i&gt; as a root directory with an &lt;i&gt;&lt;em&gt;MCP&lt;/em&gt;&lt;/i&gt; namespace.&lt;/p&gt;&lt;p&gt;Now &lt;i&gt;&lt;em&gt;app/mcp/resources/application_resource.rb&lt;/em&gt;&lt;/i&gt; defines &lt;i&gt;&lt;em&gt;MCP::ApplicationResource&lt;/em&gt;&lt;/i&gt; instead of &lt;i&gt;&lt;em&gt;Resources::ApplicationResource&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;p&gt;With that out of the way I could finally run my server&lt;/p&gt;&lt;pre data-language="shell"&gt;bin/rails s&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The Client&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;Now that the server was up and running I wanted to see if I could connect to it. The easiest way to do that is through cURL&lt;/p&gt;&lt;pre data-language="shell"&gt;curl -v http://localhost:3000/mcp/sse&lt;br&gt;* Host localhost:3000 was resolved.&lt;br&gt;* IPv6: ::1&lt;br&gt;* IPv4: 127.0.0.1&lt;br&gt;*   Trying [::1]:3000...&lt;br&gt;* Connected to localhost (::1) port 3000&lt;br&gt;* using HTTP/1.x&lt;br&gt;&amp;gt; GET /mcp/sse HTTP/1.1&lt;br&gt;&amp;gt; Host: localhost:3000&lt;br&gt;&amp;gt; User-Agent: curl/8.13.0&lt;br&gt;&amp;gt; Accept: */*&lt;br&gt;&amp;gt;&lt;br&gt;* Request completely sent off&lt;br&gt;&amp;lt; HTTP/1.1 200 OK&lt;br&gt;&amp;lt; Content-Type: text/event-stream&lt;br&gt;&amp;lt; Cache-Control: no-cache, no-store, must-revalidate&lt;br&gt;&amp;lt; Connection: keep-alive&lt;br&gt;&amp;lt; X-Accel-Buffering: no&lt;br&gt;&amp;lt; Access-Control-Allow-Origin: *&lt;br&gt;&amp;lt; Access-Control-Allow-Methods: GET, OPTIONS&lt;br&gt;&amp;lt; Access-Control-Allow-Headers: Content-Type&lt;br&gt;&amp;lt; Access-Control-Max-Age: 86400&lt;br&gt;&amp;lt; Keep-Alive: timeout=600&lt;br&gt;&amp;lt; Pragma: no-cache&lt;br&gt;&amp;lt; Expires: 0&lt;br&gt;* no chunk, no close, no size. Assume close to signal end&lt;br&gt;&amp;lt;&lt;br&gt;: SSE connection established&lt;br&gt;&lt;br&gt;event: endpoint&lt;br&gt;data: /mcp/messages?&lt;br&gt;&lt;br&gt;retry: 100&lt;br&gt;&lt;br&gt;: keep-alive 1&lt;br&gt;&lt;br&gt;: keep-alive 2&lt;br&gt;&lt;br&gt;: keep-alive 3&lt;br&gt;&lt;br&gt;: keep-alive 4&lt;br&gt;&lt;br&gt;: keep-alive 5&lt;br&gt;&lt;br&gt;event: message&lt;br&gt;data: {"jsonrpc":"2.0","method":"ping","id":715239&lt;/pre&gt;&lt;p&gt;It seems to be working.&lt;/p&gt;&lt;p&gt;But to actually use an LLM with the server I had to find a proper MCP client. This was way more difficult than I thought it would be, mostly because I'm on Linux.&lt;/p&gt;&lt;p&gt;By far the most well-known MCP client is &lt;a href="https://claude.ai/download"&gt;Claude Desktop&lt;/a&gt;, but it isn't available on Linux even though it's an Electron app... I tested a few open-source alternatives but in the end a coworker told me that &lt;a href="https://zed.dev/"&gt;Zed (the text editor)&lt;/a&gt; has an MCP client built-in and it's available on Linux.&lt;/p&gt;&lt;pre data-language="shell"&gt;paru -Sy zed-preview-bin&lt;br&gt;zeditor .&lt;/pre&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;(To get MCP support you have to join the &lt;/em&gt;&lt;/i&gt;&lt;a href="https://zed.dev/ai/agent"&gt;&lt;i&gt;&lt;em&gt;Agentic Editing Beta&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;&lt;i&gt;&lt;em&gt;)&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;p&gt;To configure Zed I had to give it a shell command with which to start the server. This is a bit odd as I want Zed to connect to an already running server - my app - but MCP, at the moment, doesn't support connecting to remote servers. This is a planned feature that's supposed to release by the end of the year - but I need it now.&lt;/p&gt;&lt;p&gt;Luckily, someone had the same problem as I and created &lt;a href="https://github.com/geelen/mcp-remote"&gt;mcp-remote&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;With that I configure Zed by giving it the following command&lt;/p&gt;&lt;pre data-language="shell"&gt;npx mcp-remote@0.0.22 http://localhost:3000/mcp/sse --allow-http --skip-browser-auth&lt;/pre&gt;&lt;p&gt;Fast MCP generates a sample tool that, given a User ID and a prefix, returns a greeting for that user. With that in mind I asked&lt;/p&gt;&lt;pre data-language="javascript"&gt;Can you greet a User with ID 636166899? &lt;/pre&gt;&lt;p&gt;To my surprise it didn't respond with a greeting but started digging through the codebase&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MDg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--ed2c5a3628081c876bbb945c43ead90cf2c009bd" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA4LCJwdXIiOiJibG9iX2lkIn19--8e214f539cf007f02a7f6589c40dbf08dea02dba/Pasted%20image%2020250505124833.png" filename="Pasted image 20250505124833.png" filesize="39145" width="635" height="348" previewable="true" presentation="gallery" caption="Claude digging through the code base to figure out how to greet a user"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Claude digging through the code base to figure out how to greet a user" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA4LCJwdXIiOiJibG9iX2lkIn19--8e214f539cf007f02a7f6589c40dbf08dea02dba/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505124833.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Claude digging through the code base to figure out how to greet a user
  &lt;/figcaption&gt;
&lt;/figure&gt;After some digging around I soon discovered that, while the server was running, it wasn't advertising any tools.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MDk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--7390c9314e92fe772c09c18b4d80b12d00f6bd31" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA5LCJwdXIiOiJibG9iX2lkIn19--ba30b951fc711372a3c8e2b6988c65667d5fc714/Pasted%20image%2020250505125117.png" filename="Pasted image 20250505125117.png" filesize="18670" width="622" height="148" previewable="true" presentation="gallery" caption="My MCP server advertising 0 tools to the client"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="My MCP server advertising 0 tools to the client" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjA5LCJwdXIiOiJibG9iX2lkIn19--ba30b951fc711372a3c8e2b6988c65667d5fc714/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505125117.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      My MCP server advertising 0 tools to the client
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The problem was that the initializer for Fast MCP uses the &lt;a href="https://api.rubyonrails.org/classes/Class.html#method-i-descendants"&gt;descendants&lt;/a&gt; method to load all tools that inherit from ApplicationTool.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;server.register_tools(*MCP::ApplicationTool.descendants)&lt;br&gt;server.register_resources(*MCP::ApplicationResource.descendants&lt;/pre&gt;&lt;p&gt;That method returns all classes that inherit from a given class, but a caveat&lt;br&gt;of that method is that it returns only descendants that have been loaded so far.&lt;/p&gt;&lt;pre data-language="ruby"&gt;Foo.descendants&lt;br&gt;# =&amp;gt; []&lt;br&gt;&lt;br&gt;class Bar &amp;lt; Foo&lt;br&gt;end&lt;br&gt;&lt;br&gt;Foo.descendants&lt;br&gt;# =&amp;gt; [Bar]&lt;/pre&gt;&lt;p&gt;Since, in development, Rails disabled eager loading no descendants of &lt;i&gt;&lt;em&gt;ApplicationTool&lt;/em&gt;&lt;/i&gt; are loaded when the initializer runs, and therefore no tools get registered with the server.&lt;/p&gt;&lt;p&gt;There are 2 easy ways around this - either enable eager loading in development (at least for this), or manually register each individual tool.&lt;/p&gt;&lt;p&gt;I opted to go with eager loading as we already had it configured to trigger when an ENV variable is set, we use that in some tests.&lt;/p&gt;&lt;p&gt;This has the downside of requiring a server restart whenever you want to test something, but as you also have to restart your MCP client so that it pulls in the new server changes this wasn't a big deal for me.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# config/environment/development.rb&lt;br&gt;&lt;br&gt;Rails.application.configure do&lt;br&gt;  # Eager load all classes if the EAGER_LOAD ENV is set&lt;br&gt;  config.eager_load = !!ENV['EAGER_LOAD']&lt;br&gt;  # ...&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;So all I had to do is restart the server with&lt;/p&gt;&lt;pre data-language="shell"&gt;EAGER_LOAD=1 bin/rails s&lt;/pre&gt;&lt;p&gt;I entered the same prompt again, and... it didn't work. It started searching through the codebase again.&lt;/p&gt;&lt;p&gt;After some more digging I concluded that, to test my MCP server I'd have to create a new Profile&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--38fa8035f3d99736b11015918cb9a85d3b751d30" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEwLCJwdXIiOiJibG9iX2lkIn19--e8733ca0e382b68e5ae5e92f1424426d6c7114fe/Pasted%20image%2020250505130656.png" filename="Pasted image 20250505130656.png" filesize="27687" width="636" height="334" previewable="true" presentation="gallery" caption="Location of the Profile tab in Zed"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Location of the Profile tab in Zed" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEwLCJwdXIiOiJibG9iX2lkIn19--e8733ca0e382b68e5ae5e92f1424426d6c7114fe/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505130656.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Location of the Profile tab in Zed
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--b685fe7153b2ae3c185fa384e11e1e679af70f1f" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjExLCJwdXIiOiJibG9iX2lkIn19--1638017f252d9249378b86d84abe8a3b194dd4b3/Pasted%20image%2020250505130722.png" filename="Pasted image 20250505130722.png" filesize="32790" width="745" height="339" previewable="true" presentation="gallery" caption="The Configure Profile section of the Profiles tab in Zed"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The Configure Profile section of the Profiles tab in Zed" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjExLCJwdXIiOiJibG9iX2lkIn19--1638017f252d9249378b86d84abe8a3b194dd4b3/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505130722.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Configure Profile section of the Profiles tab in Zed
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--4c10d22da4d941b9264159baeb48121297acedf1" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEyLCJwdXIiOiJibG9iX2lkIn19--a9b5a3012e9ad723f98005cb926de7f292605250/Pasted%20image%2020250505130837.png" filename="Pasted image 20250505130837.png" filesize="13931" width="643" height="251" previewable="true" presentation="gallery" caption="The new Profile button in Zed"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The new Profile button in Zed" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEyLCJwdXIiOiJibG9iX2lkIn19--a9b5a3012e9ad723f98005cb926de7f292605250/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505130837.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The new Profile button in Zed
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--c7eddc93a2e4052c0546dacfdbb9f7febf49a28b" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEzLCJwdXIiOiJibG9iX2lkIn19--fdd584f7fa7d3b66a37bbe02a49c2fdd8b723dfa/Pasted%20image%2020250505130903.png" filename="Pasted image 20250505130903.png" filesize="3958" width="596" height="141" previewable="true" presentation="gallery" caption="Naming a new profile in Zed"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Naming a new profile in Zed" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjEzLCJwdXIiOiJibG9iX2lkIn19--fdd584f7fa7d3b66a37bbe02a49c2fdd8b723dfa/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505130903.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Naming a new profile in Zed
  &lt;/figcaption&gt;
&lt;/figure&gt;And then configure that Profile's tools to include only my MCP server's tools.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--724a27da9c8e5772f802b78e196787c0a7bc2072" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE0LCJwdXIiOiJibG9iX2lkIn19--786f4f61f3359d1d13bce4635d9267fe82430daf/Pasted%20image%2020250505130943.png" filename="Pasted image 20250505130943.png" filesize="8490" width="623" height="173" previewable="true" presentation="gallery" caption="Configuring tools for the new profile"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Configuring tools for the new profile" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE0LCJwdXIiOiJibG9iX2lkIn19--786f4f61f3359d1d13bce4635d9267fe82430daf/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505130943.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Configuring tools for the new profile
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--9b69d4549df5a374c4cf5f78da309b650035ab05" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE1LCJwdXIiOiJibG9iX2lkIn19--63b79814b6195256fc00fa98b9d8d304a66c045b/Pasted%20image%2020250505131014.png" filename="Pasted image 20250505131014.png" filesize="24009" width="588" height="431" previewable="true" presentation="gallery" caption="Enabling individual tools from different MCP servers"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Enabling individual tools from different MCP servers" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE1LCJwdXIiOiJibG9iX2lkIn19--63b79814b6195256fc00fa98b9d8d304a66c045b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505131014.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Enabling individual tools from different MCP servers
  &lt;/figcaption&gt;
&lt;/figure&gt;With that configured I tried the prompt again and this time it worked!&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--4d4c3a80500c592d109dad787aba9e014baa082d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE2LCJwdXIiOiJibG9iX2lkIn19--8faca88bc78c039727bb82c3df0a8d664ca39246/Pasted%20image%2020250505131112.png" filename="Pasted image 20250505131112.png" filesize="29794" width="636" height="372" previewable="true" presentation="gallery" caption="Claude sending a greeting to a User through the MCP server"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Claude sending a greeting to a User through the MCP server" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE2LCJwdXIiOiJibG9iX2lkIn19--8faca88bc78c039727bb82c3df0a8d664ca39246/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505131112.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Claude sending a greeting to a User through the MCP server
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Building a tool&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;From here the team and I spent the day building various tools that would allow us to automate common support requests.&lt;/p&gt;&lt;p&gt;I implemented a simple building search tool&lt;/p&gt;&lt;pre data-language="ruby"&gt;module MCP&lt;br&gt;  class SearchBuildingsTool &amp;lt; ApplicationTool&lt;br&gt;    tool_name "search_buildings"&lt;br&gt;    description "Searches for buildings"&lt;br&gt;&lt;br&gt;    arguments do&lt;br&gt;      optional(:building_ids).array(:integer).description("IDs of the Buildings to look up")&lt;br&gt;      optional(:query).maybe(:string).description("The search query")&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    def call(building_ids: nil, query: nil,)&lt;br&gt;      buildings = Building.all&lt;br&gt;&lt;br&gt;      if building_ids.present?&lt;br&gt;        buildings = buildings.where(id: building_ids)&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      if query.present?&lt;br&gt;        buildings = buildings.where("name LIKE :query OR handle LIKE :query", query: "%#{query}%")&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      buildings&lt;br&gt;	    .limit(50)&lt;br&gt;	    .order(name: :desc)&lt;br&gt;	    .to_json(only: [:id, :name, :handle, :address])&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;that worked quite well&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--84c73e8cd48a3a14031b992fd83e56afbe06961f" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE3LCJwdXIiOiJibG9iX2lkIn19--378cff974d68c25ff9597a3842532696ba7e69ac/Pasted%20image%2020250505134334.png" filename="Pasted image 20250505134334.png" filesize="33672" width="633" height="311" previewable="true" presentation="gallery" caption="Demo of the Building search tool"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Demo of the Building search tool" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE3LCJwdXIiOiJibG9iX2lkIn19--378cff974d68c25ff9597a3842532696ba7e69ac/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505134334.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the Building search tool
  &lt;/figcaption&gt;
&lt;/figure&gt;Then I wanted to implement Access Log search. But to search Access Logs you need a building ID. I wasn't sure if the LLM could combine multiple tools by first searching for the building, getting its ID and then searching for the access logs with that ID. And I couldn't find a definitive answer online so I hoped for the best and implemented the following&lt;p&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;module MCP&lt;br&gt;  class SearchAccessLogsTool &amp;lt; ApplicationTool&lt;br&gt;    tool_name "search_access_logs"&lt;br&gt;    description "Searches through Access Logs and returns the fist 25 results"&lt;br&gt;&lt;br&gt;    arguments do&lt;br&gt;      required(:building_id).filled(:integer).description("ID of the Building that we are searching in")&lt;br&gt;      optional(:query).maybe(:string).description("The search query")&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    def call(building_id: nil, query: nil)&lt;br&gt;      building = begin&lt;br&gt;        Building.find(building_id)&lt;br&gt;      rescue ActiveRecord::RecordNotFound&lt;br&gt;        return "Building not found"&lt;br&gt;      end&lt;br&gt;&lt;br&gt;	  building&lt;br&gt;	    .access_logs&lt;br&gt;	    .search({ query: query })&lt;br&gt;	    .order(logged_at: :desc)&lt;br&gt;	    .limit(25)&lt;br&gt;	    .to_json(only: [&lt;br&gt;	      :id, &lt;br&gt;	      :logged_at, &lt;br&gt;	      :grantor, &lt;br&gt;	      :description,&lt;br&gt;	      :access_point_id,&lt;br&gt;	      :status&lt;br&gt;	    ])&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;To my surprise this worked! It reused the ID it found in the previous response to search for the access logs.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--efcc66a9e508fe3f2d3014061072717ec57c5d88" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE4LCJwdXIiOiJibG9iX2lkIn19--d3a126e768fecd71060c59603388289b8ffe4338/Pasted%20image%2020250505134942.png" filename="Pasted image 20250505134942.png" filesize="144139" width="636" height="1361" previewable="true" presentation="gallery" caption="Claude reusing previous result to solve new problems"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Claude reusing previous result to solve new problems" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE4LCJwdXIiOiJibG9iX2lkIn19--d3a126e768fecd71060c59603388289b8ffe4338/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505134942.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Claude reusing previous result to solve new problems
  &lt;/figcaption&gt;
&lt;/figure&gt;I was curious if it could actually combine tools so I started a new chat and asked the same question.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MTk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--3ebb6f06fe12ef5d95c6dc052a3dba8f312eec26" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE5LCJwdXIiOiJibG9iX2lkIn19--c85a5fd8749c4492fbbd07399a49f47588753341/Pasted%20image%2020250505135346.png" filename="Pasted image 20250505135346.png" filesize="127800" width="634" height="1096" previewable="true" presentation="gallery" caption="Claude combining multiple tools in a single response to solve a problem"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Claude combining multiple tools in a single response to solve a problem" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjE5LCJwdXIiOiJibG9iX2lkIn19--c85a5fd8749c4492fbbd07399a49f47588753341/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250505135346.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Claude combining multiple tools in a single response to solve a problem
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The response blew my mind. It could combine tools! To me, this was both incredible and scary. Kind of like seeing primates &lt;a href="https://www.youtube.com/watch?v=FA3LN4vqtlM"&gt;use tools&lt;/a&gt; or &lt;a href="https://www.bbc.com/worklife/article/20180406-what-monkeys-can-teach-us-about-money"&gt;develop their own economy&lt;/a&gt;.&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Takeaways&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;MCP is a very "loose" protocol. It does allow you to specify what inputs your tool has, which are required and which are optional, but it's not expressive enough specify what the input should look like.&lt;/p&gt;&lt;p&gt;For example, in the Access Logs search I didn't specify what the query string should look like - my mistake - so the LLM just takes its best guess when you ask it something complex like "Can you give me all access logs created in the last hour in the building Crimson?".&lt;/p&gt;&lt;p&gt;I expected it to get all the access logs and then filter them on its own, but that's not what happened. Instead it passed &lt;i&gt;&lt;em&gt;"logged_at:&amp;gt;=2025-05-05T07:36:31"&lt;/em&gt;&lt;/i&gt; as the query. And, honestly, I can't blame it.&lt;/p&gt;&lt;p&gt;I later refined my tools with very detailed descriptions like this one&lt;/p&gt;&lt;pre data-language="ruby"&gt;module MCP&lt;br&gt;  class SearchAccessLogsTool &amp;lt; ApplicationTool&lt;br&gt;    tool_name "search_access_logs"&lt;br&gt;    description &amp;lt;&amp;lt;~MD&lt;br&gt;      Searches for Access Logs in a given building and returns&lt;br&gt;      a JSON string containing an array of up to 25 objects.&lt;br&gt;      The list is sorted in descending order by the time &lt;br&gt;      the access log was logged.&lt;br&gt;      Inputs:&lt;br&gt;      - building_id [Integer] (the ID of the building in which to search for access logs)&lt;br&gt;      - query [String, null] (a plain string that can contain any part ot the name of the grantor, the id of the access log, the description, the id of the associated access point)&lt;br&gt;      - logged_after [String, null] (An ISO8601 timestamp of the earliest time a log was recorded)&lt;br&gt;      - logged_before [String, null] (An ISO8601 timestamp of the latest time a log was recorded)&lt;br&gt;      - statuses [Array[String], null] (A comma-separated list of statuses)&lt;br&gt;      Output:&lt;br&gt;      - id [Integer] (The ID of the access log record)&lt;br&gt;      - logged_at [String] (ISO8601 timestamp of the time the log was recorded)&lt;br&gt;      - grantor [String, null] (Name of the person that granted this access, a null value means that the system granted the access)&lt;br&gt;      - description [String] (A description of what happened)&lt;br&gt;      - access_point_id [Integer] (ID of the access point that was accessed)&lt;br&gt;      - status [String] (The access point's state after this access)&lt;br&gt;&lt;br&gt;      Statuses:&lt;br&gt;      - open - means that the access point was unlocked and that someone opened it&lt;br&gt;      - closed - means that the access point was unlocked, opened and then closed&lt;br&gt;      - left_ajar - means that the access point was unlocked, and then left open for more than 1min&lt;br&gt;      - denied - the access point never unlocked&lt;br&gt;      &lt;br&gt;      If the building couldn't be found you'll get a message back stating that.&lt;br&gt;    MD&lt;br&gt;&lt;br&gt;    arguments do&lt;br&gt;      required(:building_id)&lt;br&gt;        .filled(:integer)&lt;br&gt;        .description("ID of the Building that we are searching in")&lt;br&gt;      optional(:query)&lt;br&gt;        .maybe(:string)&lt;br&gt;        .description("The search query which can contain any part of the grantors name, id of the record, id of the access point, or part of the description; any other input will be ignored")&lt;br&gt;      optional(:status)&lt;br&gt;	    .maybe(:string)&lt;br&gt;	    .description("Comma-separated list of statuses - valid statuses are open, closed, left_ajar, denied; any other statuses will be ignored")&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    def call(**args)&lt;br&gt;      # ...&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;The more information that I gave the LLM the better the result was. Something to watch out for is that you:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Describe the output &lt;i&gt;&lt;em&gt;(it's as important as describing the input&lt;/em&gt;&lt;/i&gt;)&lt;/li&gt;&lt;li&gt;Describe error states&lt;/li&gt;&lt;li&gt;List all possible enum values and describe what each value means&lt;/li&gt;&lt;li&gt;Explicitly state what format you expect for an input (what kind of timestamp do you expect, can it be nullable, etc.)&lt;/li&gt;&lt;li&gt;Explicitly state what's not allowed &lt;i&gt;&lt;em&gt;(e.g. additional enum values besides the ones listed)&lt;/em&gt;&lt;/i&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;One more thing. With smaller LLMs, like Llama 3 7b, I had to normalize input values. E.g. I'd tell it that I expect &lt;i&gt;&lt;em&gt;"building"&lt;/em&gt;&lt;/i&gt; as an enum value but it would pass me &lt;i&gt;&lt;em&gt;"Buildings"&lt;/em&gt;&lt;/i&gt;. Or I'd tell it that a field is a nullable String but it would still pass an empty string when it had no value. But Rails makes it really easy to accommodate for such mistakes.&lt;/p&gt;&lt;pre data-language="ruby"&gt;value.presence&amp;amp;.to_s&amp;amp;.strip&amp;amp;.singularize&amp;amp;.underscore&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;p&gt;Integrating our existing app with AI was surprisingly easy. We just had to expose tools and the LLM would figure out how to use them. In 8 hours we managed to set up an MCP server, configure our clients, and expose enough tools to speed up a dozen common support requests, which is amazing.&lt;/p&gt;&lt;p&gt;In this hackathon, being a fully remote team with members in the EU and the US felt like a superpower. The EU team solved all the setup problems, so by the time the US team came online they could jump straight into building tools and testing ideas. For the price of 8 hours we got 14.&lt;/p&gt;&lt;p&gt;I still want to explore how to add permissions and some guard rails so that the LLM can't do too much damage if it goes haywire. I intentionally skipped that during the hackathon as it wasn't important.&lt;/p&gt;&lt;p&gt;This project was a technical success, but I'm not sure if it's the right direction to go in. This integration makes it so that developers get rarely involved in some types of support requests. As a developer, support is less about solving a problem and is more about figuring out customers' pain points and how to make the overall experience better.&lt;/p&gt;&lt;p&gt;I'm not sure how to strike a good balance between automating laborious tasks and surfacing common problems. That's also something I'll have to explore.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last week we had an AI hackathon at work during which we added an MCP server to our Rails app. This was much easier than I expected, the result was both impressive and scary, and I learned a few things about MCP that I didn't expect.Goal

My team wanted to help our support team by automating some...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/42</id>
    <published>2025-04-24T06:00:00Z</published>
    <updated>2026-03-12T12:11:10Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/building-simpl-77CAzym51p5a"/>
    <title>Building Simpl息</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;This week I went for a coffee with a friend. As we talked the topic of hobbies came up. I mentioned that I was having a lot of fun working on a toy project over the past week, and then I showed it to him.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81OTk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--f164f6d8efa8aa6de68f8fed844a7246428c3184" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk5LCJwdXIiOiJibG9iX2lkIn19--7d29c3dba06b94ef59b48739e0e32f34c9f27c1e/Screencast%20From%202025-04-23%2012-55-40.mp4" filename="Screencast From 2025-04-23 12-55-40.mp4" filesize="1040243" width="504.0" height="942.0" previewable="true" presentation="gallery" caption="Demo"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk5LCJwdXIiOiJibG9iX2lkIn19--7d29c3dba06b94ef59b48739e0e32f34c9f27c1e/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2012-55-40.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk5LCJwdXIiOiJibG9iX2lkIn19--7d29c3dba06b94ef59b48739e0e32f34c9f27c1e/Screencast%20From%202025-04-23%2012-55-40.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo
  &lt;/figcaption&gt;
&lt;/figure&gt;Him: So you are dabbling in React?&lt;br&gt;Me: No, this is just Rails.&lt;br&gt;Him: Which libraries did you use?&lt;br&gt;Me: None, this is as vanilla as it gets.&lt;br&gt;Him: What!?&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Backstory&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;For the past 12 months, each month I built an app within a strict time-box of 12 hours.&lt;br&gt;&lt;/p&gt;&lt;p&gt;If the project went overtime, and wasn’t at most 4 hours away from completion, I’d abandon it. If a project failed, and a lot of them did in the beginning, I’d reflect what led to that and tried to fix it in the next one.&lt;br&gt;&lt;/p&gt;&lt;p&gt;My goal is to practice pragmatic decision making and to curb &lt;a href="https://stanko.io/fight-perfection-roooUsSEfduw"&gt;my perfectionism - which has started to show again&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The time-box really helped me make better decisions. At first all my apps failed because I either over-estimated my speed, or under-estimated the complexity and unknowns. But around the third one I got better at both. Then came the hard part - learning to strike a balance between quality work, functionality, and not obsessing over details.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Simpliki&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Simpliki is the 12th app I’ve built.&lt;br&gt;&lt;/p&gt;&lt;p&gt;It’s an app for practicing diaphragmatic breathing.&lt;br&gt;I got the idea for it from my girlfriend. She uses a similar app in her mindfulness practice.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The name is a combination of “simple” and the Japanese word “iki”, which means “breath”.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi82MDI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--3406a213410da91d621b99df6c2936c339971920" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjAyLCJwdXIiOiJibG9iX2lkIn19--0f16b3820e86d68949a78f8d84fc99e4eeeb4d87/image.png" filename="image.png" filesize="48587" width="1264" height="550" previewable="true" presentation="gallery" caption="Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NjAyLCJwdXIiOiJibG9iX2lkIn19--0f16b3820e86d68949a78f8d84fc99e4eeeb4d87/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/image.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Google Translate result for translating "breath" to Japanese
  &lt;/figcaption&gt;
&lt;/figure&gt;You can practice breathing with Simpliki at &lt;a href="https://simpliki.app/"&gt;simpliki.app&lt;/a&gt;. And you can &lt;a href="https://github.com/monorkin/simpliki"&gt;peruse the source-code on GitHub&lt;/a&gt;.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Starting from the center&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;The best way to finish a project is to start from the center and work your way from there. I focus on what makes the app work - what makes it what it is - and tackle everything else later. That way I can cut less important bits if I have to.&lt;br&gt;&lt;/p&gt;&lt;p&gt;For Simpliki the center is the breathing exercise - the place where you practice diaphragmatic breathing - without that there is no Simpliki.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NjQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--0ce0ae5b2badc1577384bbccaf939e91bdd61e3a" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY0LCJwdXIiOiJibG9iX2lkIn19--eb23ec4922383c9baa101625866dbf3519bce550/simpliki_center_1.png" filename="simpliki_center_1.png" filesize="33336" width="1620" height="400" previewable="true" presentation="gallery" caption="Simpliki's center"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Simpliki's center" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY0LCJwdXIiOiJibG9iX2lkIn19--eb23ec4922383c9baa101625866dbf3519bce550/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_center_1.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Simpliki's center
  &lt;/figcaption&gt;
&lt;/figure&gt;But I also need a way to get to the breathing exercise - an index screen or something similar.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Personally, when I want to relax and practice mindfulness I don’t like to have too many options to choose from - it takes me out of the experience. So I decided to add a “home” screen with a single button that links to a random exercise.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NjU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a3a1c2b00ba687020493b38d4a8b245bd4588373" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY1LCJwdXIiOiJibG9iX2lkIn19--0d757933ed996e3b4d8b8ba392afe1d05086b9bc/simpliki_center_2.png" filename="simpliki_center_2.png" filesize="58288" width="1620" height="550" previewable="true" presentation="gallery" caption="Simpliki's two centers"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Simpliki's two centers" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY1LCJwdXIiOiJibG9iX2lkIn19--0d757933ed996e3b4d8b8ba392afe1d05086b9bc/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_center_2.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Simpliki's two centers
  &lt;/figcaption&gt;
&lt;/figure&gt;My girlfriend likes to practice specific exercises so I added an “Explore” button that leads to an index page with all exercises.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;An app can have many centers interconnected with different features. I think of the exercise and the home screen as two separate centers, and the index is just a feature between them.&lt;br&gt;&lt;br&gt;I learned this from &lt;a href="https://en.wikipedia.org/wiki/Christopher_Alexander"&gt;Christopher Alexander's design theory&lt;/a&gt;. The most apt analogy I found is &lt;a href="https://www.researchgate.net/publication/361518671_Finding_the_Center_Christopher_Alexander's_Concept_of_Centers"&gt;in a paper that compares this design principle to patterns of a carpet&lt;/a&gt;.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NjY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--e90345738a122ea6e585d7f1022b6b802b9d64ff" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY2LCJwdXIiOiJibG9iX2lkIn19--7f12f742db615df333cfdf444b4d503374066b98/simpliki_center_3.png" filename="simpliki_center_3.png" filesize="88546" width="1620" height="650" previewable="true" presentation="gallery" caption="Simpliki's two centers and the features between them"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Simpliki's two centers and the features between them" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY2LCJwdXIiOiJibG9iX2lkIn19--7f12f742db615df333cfdf444b4d503374066b98/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_center_3.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Simpliki's two centers and the features between them
  &lt;/figcaption&gt;
&lt;/figure&gt;Now that I have an idea what I'm building I'll create a breadboard so that I can work out problems on paper instead of experimenting in code. I skipped this step for Simpliki as it was, well, simple. But the breadboard would look something like this.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Njc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--2f52f62c956ae8324be7faa8c1034b73737d10a1" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY3LCJwdXIiOiJibG9iX2lkIn19--c6d2bc739616aa4db70b2da1212b88d9d4886e1f/simpliki_breadboard.png" filename="simpliki_breadboard.png" filesize="173422" width="1620" height="850" previewable="true" presentation="gallery" caption="The breadboard"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The breadboard" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY3LCJwdXIiOiJibG9iX2lkIn19--c6d2bc739616aa4db70b2da1212b88d9d4886e1f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_breadboard.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The breadboard
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This also helps me figure out the domain language - what models I have and what their attributes and methods are called.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Njg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--eb6f9d9306740428c8d0d1d68494aa265ef0b81f" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY4LCJwdXIiOiJibG9iX2lkIn19--60218ce67eb9fd2e125ab0d460f43b5d1c403288/simpliki_domain.png" filename="simpliki_domain.png" filesize="56346" width="1620" height="470" previewable="true" presentation="gallery" caption="The domain"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The domain" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY4LCJwdXIiOiJibG9iX2lkIn19--60218ce67eb9fd2e125ab0d460f43b5d1c403288/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_domain.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The domain
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;b&gt;&lt;strong&gt;Design&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I had a clear vibe I wanted to go for - &lt;a href="https://endel.io/"&gt;Endel&lt;/a&gt; + &lt;a href="https://www.headspace.com/"&gt;Headspace&lt;/a&gt;. I like Endel's minimalist and monochrome style - it has a mystical, simple, and concise vibe that I wanted to replicate. And I find the organic, whimsical, animated shapes and play button from Headspace calming - exactly the vibe I want for a mindfulness app.&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Njk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--d0811d4edb495ed79ca2cd55d60d4d9db53cbbac" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY5LCJwdXIiOiJibG9iX2lkIn19--02853a32cc20df10c1a0debb867d5419836a0d10/simpliki_endel.png" filename="simpliki_endel.png" filesize="1730833" width="1290" height="2796" previewable="true" presentation="gallery" caption="Endel"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Endel" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTY5LCJwdXIiOiJibG9iX2lkIn19--02853a32cc20df10c1a0debb867d5419836a0d10/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_endel.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Endel
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--e7123a91df53766950f0824c2592d98b31088ce5" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcwLCJwdXIiOiJibG9iX2lkIn19--745812a03a9951e6854d933107de210d0b23c084/simpliki_headspace.png" filename="simpliki_headspace.png" filesize="121427" width="1290" height="2796" previewable="true" presentation="gallery" caption="Headspace"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Headspace" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcwLCJwdXIiOiJibG9iX2lkIn19--745812a03a9951e6854d933107de210d0b23c084/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_headspace.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Headspace
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;So I decided to combine the two for Simpliki. I'd use Endel's color-scheme and the animated organic shapes from Headspace.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--3572db63b2cbb6de106494c398ed32b286dfcda9" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcxLCJwdXIiOiJibG9iX2lkIn19--7c5344957a7e58fff7ec3ccac699f5c028e80c3a/Pasted%20image%2020250423095110.png" filename="Pasted image 20250423095110.png" filesize="39352" width="496" height="941" previewable="true" presentation="gallery" caption="Simpliki's UI"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Simpliki's UI" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcxLCJwdXIiOiJibG9iX2lkIn19--7c5344957a7e58fff7ec3ccac699f5c028e80c3a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250423095110.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Simpliki's UI
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Starting a new project&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I decided to use Tailwind because I like working with it. And I chose SQLite as the database because it's the simplest one to use out of the presets. I honestly don't even need a database for this app, but it's easier to use one than not.&lt;/p&gt;&lt;pre data-language="shell"&gt;rails new simpliki --css=tailwind&lt;/pre&gt;&lt;p&gt;Then in that project I generated my models and a controller&lt;/p&gt;&lt;pre data-language="shell"&gt;bin/rails g model Exercise name:string&lt;br&gt;&lt;br&gt;bin/rails g model Exercise::Step exercise:belongs_to action:string duration:integer position:integer&lt;br&gt;&lt;br&gt;bin/rails g scaffold_controller Exercise&lt;/pre&gt;&lt;p&gt;I removed everything that I didn't need, added a "home" action to the controller, a view for that action, and pointed the root route to that action.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I created a few fixtures for &lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/exercises.yml"&gt;Exercises&lt;/a&gt; and &lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/exercise/steps.yml"&gt;Exercise::Steps&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Over the past 5 years I've completely changed my mind about fixtures. I used to think that they are finicky and convoluted compared to factories. But I've come to like them more after I've seen the downsides of factories over the years - they are extremely slow, it's hard to manage what will get created or you have to define every single record you want to create in every single test.&lt;br&gt;&lt;/p&gt;&lt;p&gt;My favorite advantage of fixtures is:&lt;/p&gt;&lt;pre data-language="shell"&gt;bin/rails db:fixtures:load&lt;/pre&gt;&lt;p&gt;This will take all my fixtures and put them in my current &lt;i&gt;&lt;em&gt;(development or production)&lt;/em&gt;&lt;/i&gt; database.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Now I don't need to create a way to add or edit Exercises, or add and edit them manually through the console, before I can start working on the show page. I can just load in a few exercises and use them. Best of all, I get to see and use the exact same data that's in my tests - no more trying to figure out what went wrong, I can just open the same Exercise and see.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I started working on the views. At first I just added links between the pages, added a header that said "Simpl息" on each page, added a plain HTML "Practice" button on the exercise show page, and created a very simple index page that shows a list of exercises.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The ruby tag&lt;/strong&gt;&lt;/b&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--02f737247c999aa40b23778600fa17c54e0f95eb" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcyLCJwdXIiOiJibG9iX2lkIn19--3f9fa78cc6a9ec3a413e92065bafee40d7a04b73/Pasted%20image%2020250423133538.png" filename="Pasted image 20250423133538.png" filesize="19187" width="752" height="388" previewable="true" presentation="gallery" caption="Simpliki's logo"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Simpliki's logo" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTcyLCJwdXIiOiJibG9iX2lkIn19--3f9fa78cc6a9ec3a413e92065bafee40d7a04b73/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250423133538.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Simpliki's logo
  &lt;/figcaption&gt;
&lt;/figure&gt;Then I remembered reading about the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/ruby"&gt;HTML ruby element&lt;/a&gt; which is used to show the pronunciation of characters. I thought it would be a nice way to show where the app's name comes from.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;h1 id="logo" class="font-serif"&amp;gt;&lt;br&gt;  &amp;lt;span class="mt-4"&amp;gt;Simpl&amp;lt;/span&amp;gt;&lt;br&gt;  &amp;lt;ruby&amp;gt;&lt;br&gt;	  息&lt;br&gt;	  &amp;lt;rt class="text-gray-500"&amp;gt;iki&amp;lt;/rt&amp;gt;&lt;br&gt;  &amp;lt;/ruby&amp;gt;&lt;br&gt;&amp;lt;/h1&amp;gt;&lt;/pre&gt;&lt;p&gt;It took about a minute to add and style the "iki" - plain HTML is amazing.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Animating SVGs&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Next I wanted to animate the play button. I've done similar things before so I was quite confident that I could do it quickly.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--e6b966257d4373730e1878054d4ff0f6ac176843" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTczLCJwdXIiOiJibG9iX2lkIn19--16db1caded3d78cfcbaa918987a3deeeae11a09a/Screencast%20From%202025-04-23%2014-32-42%20(1).mp4" filename="Screencast From 2025-04-23 14-32-42 (1).mp4" filesize="176064" width="596.0" height="546.0" previewable="true" presentation="gallery" caption="Demo of the circle animation"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTczLCJwdXIiOiJibG9iX2lkIn19--16db1caded3d78cfcbaa918987a3deeeae11a09a/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2014-32-42%20(1).mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTczLCJwdXIiOiJibG9iX2lkIn19--16db1caded3d78cfcbaa918987a3deeeae11a09a/Screencast%20From%202025-04-23%2014-32-42%20(1).mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the circle animation
  &lt;/figcaption&gt;
&lt;/figure&gt;The basic idea is to have an SVG with a single path that consists of 16 points with &lt;a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve"&gt;Bezier curves&lt;/a&gt; that form a circle. The number of points determines how organic and uneven the circle will look in the end - 8 is too blocky, 24 is too smooth, 16 is just right.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--6a44c343151421735b73a05fec83bd29c88ec8ef" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc0LCJwdXIiOiJibG9iX2lkIn19--7fe15faac9b19a7439c61539270aaf2a66fb04a7/simpliki_basic_circle.png" filename="simpliki_basic_circle.png" filesize="56180" width="1620" height="500" previewable="true" presentation="gallery" caption="Basic SVG circle with 16 points and a Bezier curve"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Basic SVG circle with 16 points and a Bezier curve" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc0LCJwdXIiOiJibG9iX2lkIn19--7fe15faac9b19a7439c61539270aaf2a66fb04a7/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/simpliki_basic_circle.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Basic SVG circle with 16 points and a Bezier curve
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Then, every 10ms I change the position of each point slightly and adjust their Bezier curve points to animate the circle.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;To make it undulate - move like there are ripples going across it - I can pull or push one point after another in or out, and then adjust the Bezier curve points to make it look hand-drawn.&lt;br&gt;&lt;/p&gt;&lt;p&gt;That's an old computer science trick I learned in college. It sounds complicated, but it's quite straightforward.&lt;br&gt;&lt;/p&gt;&lt;p&gt;There is just one thing you have to know - if you plot &lt;i&gt;&lt;em&gt;x = cos(a)&lt;/em&gt;&lt;/i&gt; and &lt;i&gt;&lt;em&gt;y = sin(a)&lt;/em&gt;&lt;/i&gt; where &lt;i&gt;&lt;em&gt;a&lt;/em&gt;&lt;/i&gt; is any value between 0 and 2&lt;i&gt;&lt;em&gt;PI&lt;/em&gt;&lt;/i&gt; you'll get a circle of radius 1. Then to get any size circle you just multiply everything by the radius you want.&lt;/p&gt;&lt;pre data-language="js"&gt;const NUM_POINTS = 16&lt;br&gt;const RADIUS = 100&lt;br&gt;&lt;br&gt;function generatePoints(time) {&lt;br&gt;  const circlePoints = []&lt;br&gt;	&lt;br&gt;  for (let index = 0; index &amp;lt; NUM_POINTS; i++) {&lt;br&gt;    // Calculate at what angle the point is supposed to be&lt;br&gt;    const angle = (point.index / NUM_POINTS) * Math.PI * 2&lt;br&gt;	&lt;br&gt;    // Convert the angle to coordinates&lt;br&gt;    const x = RADIUS * Math.cos(angle)&lt;br&gt;    const y = RADIUS * Math.sin(angle)&lt;br&gt;	&lt;br&gt;    circlePoints.push([x, y])&lt;br&gt;  }&lt;br&gt;&lt;br&gt;  return circlePoints&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--c6a0dca8729b91feba0a74a9b90fa1641acecbb5" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc1LCJwdXIiOiJibG9iX2lkIn19--778417d03be8eeb024c6a0d582871cb3ade1c729/Pasted%20image%2020250423150750.png" filename="Pasted image 20250423150750.png" filesize="12559" width="620" height="550" previewable="true" presentation="gallery" caption="The most basic circle"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The most basic circle" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc1LCJwdXIiOiJibG9iX2lkIn19--778417d03be8eeb024c6a0d582871cb3ade1c729/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250423150750.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The most basic circle
  &lt;/figcaption&gt;
&lt;/figure&gt;To form waves, I can change the radius for each point using any function that also includes COS and SIN. That way each point moves in its own little circle which, when put all together, looks like a wave. And to make it consistent the SIN and COS have to include the current time.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="js"&gt;const NUM_POINTS = 16&lt;br&gt;const RADIUS = 100&lt;br&gt;const RIPPLE_FREQ = 0.5 // determines how quickly the waves move&lt;br&gt;const RIPPLE_AMPLITUDE = 5 // determines how big the waves are&lt;br&gt;&lt;br&gt;const circlePoints = []&lt;br&gt;&lt;br&gt;function generatePoints(time) {&lt;br&gt;  const circlePoints = []&lt;br&gt;	&lt;br&gt;  for (let index = 0; index &amp;lt; NUM_POINTS; i++) {&lt;br&gt;    // Calculate at what angle the point is supposed to be&lt;br&gt;    const angle = (point.index / NUM_POINTS) * Math.PI * 2&lt;br&gt;&lt;br&gt;    // Generate a new radius&lt;br&gt;    // the values were completely arbitrarily chosen&lt;br&gt;    // you could also just use sin(time) + cos(time)&lt;br&gt;    // this just looks nice to me&lt;br&gt;    const rippleY = Math.sin(time * RIPPLE_FREQ + angle)&lt;br&gt;    const rippleX = Math.cos(time * RIPPLE_FREQ * 0.7 + angle * 1.3)&lt;br&gt;    const ripple = (rippleY * 0.6 + rippleX * 0.4) * RIPPLE_AMPLITUDE&lt;br&gt;	&lt;br&gt;    // Convert the angle to coordinates&lt;br&gt;    const x = RADIUS * Math.cos(angle) + (ripple * Math.cos(angle))&lt;br&gt;    const y = RADIUS * Math.sin(angle) + (ripple * Math.sin(angle))&lt;br&gt;	&lt;br&gt;    circlePoints.push([x, y])&lt;br&gt;  }&lt;br&gt;  &lt;br&gt;  return circlePoints&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NzY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--b077dbc28b4c5a8ec36ade13945b21793a6c1360" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc2LCJwdXIiOiJibG9iX2lkIn19--c8552d1aa3598e7c3aaf71e86940a623f2fcd8e5/Pasted%20image%2020250423150838.png" filename="Pasted image 20250423150838.png" filesize="12835" width="550" height="544" previewable="true" presentation="gallery" caption="Circle with ripples"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Circle with ripples" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc2LCJwdXIiOiJibG9iX2lkIn19--c8552d1aa3598e7c3aaf71e86940a623f2fcd8e5/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250423150838.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Circle with ripples
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/circle_animation_controller.js#L88-L92"&gt;Then I can just move the bezier curve anchors in the same way to get a hand-drawn look.&lt;/a&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Nzc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--addec6cbd7a1ed0291fe010478e1be3bfaf0a8ca" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc3LCJwdXIiOiJibG9iX2lkIn19--5d66ceed2195101d77bbadfc3f5878b7827662e6/Pasted%20image%2020250423151922.png" filename="Pasted image 20250423151922.png" filesize="12619" width="503" height="519" previewable="true" presentation="gallery" caption="Circle with ripples and fixed Bezier curves"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Circle with ripples and fixed Bezier curves" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc3LCJwdXIiOiJibG9iX2lkIn19--5d66ceed2195101d77bbadfc3f5878b7827662e6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250423151922.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Circle with ripples and fixed Bezier curves
  &lt;/figcaption&gt;
&lt;/figure&gt;The end effect is subtle, but it makes the circle look friendlier.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;View transitions&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Next I animated the view transitions. This takes maybe 10 minutes but makes the app feel way more modern and responsive.&lt;br&gt;&lt;/p&gt;&lt;p&gt;First I &lt;a href="https://turbo.hotwired.dev/handbook/drive#view-transitions"&gt;enabled view transitions in Turbo Drive&lt;/a&gt; by adding this meta tag to the head section of my layout&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;meta name="view-transition" content="same-origin" /&amp;gt;&lt;/pre&gt;&lt;p&gt;Just adding this one line will already add a fade effect to all page transitions. Then to move individual elements around, like the logo, I have to add it a &lt;i&gt;&lt;em&gt;view-transition-name&lt;/em&gt;&lt;/i&gt; CSS property with a unique name.&lt;/p&gt;&lt;pre data-language="css"&gt;#logo {&lt;br&gt;  view-transition-name: logo;&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;After that the browser animates the transitions on its own.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Nzg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--fc2551826bbc63aa14fffd40f0d2b4467427a7b5" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc4LCJwdXIiOiJibG9iX2lkIn19--5f064150546bbb29f1e308074d6fd5bfd60e6d57/Screencast%20From%202025-04-23%2015-35-32.mp4" filename="Screencast From 2025-04-23 15-35-32.mp4" filesize="390051" width="850.0" height="1510.0" previewable="true" presentation="gallery" caption="Demo of the logo moving with page navigation"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc4LCJwdXIiOiJibG9iX2lkIn19--5f064150546bbb29f1e308074d6fd5bfd60e6d57/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2015-35-32.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc4LCJwdXIiOiJibG9iX2lkIn19--5f064150546bbb29f1e308074d6fd5bfd60e6d57/Screencast%20From%202025-04-23%2015-35-32.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the logo moving with page navigation
  &lt;/figcaption&gt;
&lt;/figure&gt;For the explore page I wanted to add an animation where the list slides in from the bottom of the page. Luckily this can be done with a few lines of CSS.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="css"&gt;@keyframes slide-to-top-from-out-of-view {&lt;br&gt;  0% {&lt;br&gt;    transform: translateY(100vh);&lt;br&gt;    opacity: 0;&lt;br&gt;  }&lt;br&gt;  100% {&lt;br&gt;    transform: translateY(0);&lt;br&gt;    opacity: 1;&lt;br&gt;  }&lt;br&gt;}&lt;br&gt;&lt;br&gt;#exercises {&lt;br&gt;  view-transition-name: exercises;&lt;br&gt;}&lt;br&gt;&lt;br&gt;html:not([data-same-page-visit])[data-turbo-visit-direction="forward"]::view-transition-new(exercises) {&lt;br&gt;  animation: 0.25s ease-out slide-to-top-from-out-of-view both;&lt;br&gt;}&lt;br&gt;&lt;br&gt;&lt;/pre&gt;&lt;p&gt;This tells the browser to apply a &lt;i&gt;&lt;em&gt;slide-to-top-from-out-of-view&lt;/em&gt;&lt;/i&gt; animation for any new exercises list that appear.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Nzk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--63500747766c24cdb253beb09f5d6b5af56cf1bd" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc5LCJwdXIiOiJibG9iX2lkIn19--40b88ff6661561980be694c97392aa8c445cce9b/Screencast%20From%202025-04-23%2015-23-44.mp4" filename="Screencast From 2025-04-23 15-23-44.mp4" filesize="141177" width="850.0" height="1510.0" previewable="true" presentation="gallery" caption="Demo of the exercise list sliding in from the bottom of the screen"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc5LCJwdXIiOiJibG9iX2lkIn19--40b88ff6661561980be694c97392aa8c445cce9b/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2015-23-44.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTc5LCJwdXIiOiJibG9iX2lkIn19--40b88ff6661561980be694c97392aa8c445cce9b/Screencast%20From%202025-04-23%2015-23-44.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the exercise list sliding in from the bottom of the screen
  &lt;/figcaption&gt;
&lt;/figure&gt;There is one caveat, View Transitions aren't supported on Firefox. This only works on Chromium and WebKit based browsers. As one of the 2% of global Firefox users I don't mind, the app still works without the transitions.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The breathing animation&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Now that I have an animated circle I want to create a breathing animation to help people pace their breath.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The circle should grow when you have to breathe in, and shrink when you have to breathe out.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81ODA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--f62f0df1726cdce72c6249157586f2c3ee5990c9" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgwLCJwdXIiOiJibG9iX2lkIn19--ad83c1b2f3c5e2862f352e626c80ac93f02c3595/Screencast%20From%202025-04-23%2016-31-36.mp4" filename="Screencast From 2025-04-23 16-31-36.mp4" filesize="325957" width="658.0" height="710.0" previewable="true" presentation="gallery" caption="Demo of the breathing animation"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgwLCJwdXIiOiJibG9iX2lkIn19--ad83c1b2f3c5e2862f352e626c80ac93f02c3595/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2016-31-36.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgwLCJwdXIiOiJibG9iX2lkIn19--ad83c1b2f3c5e2862f352e626c80ac93f02c3595/Screencast%20From%202025-04-23%2016-31-36.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the breathing animation
  &lt;/figcaption&gt;
&lt;/figure&gt;To animate this I need to create three circles - a maximum indicator, a minimum indicator and a progress indicator.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;The maximum indicator is static and just has an opacity of 10%. The minimum indicator is mostly static after an initial animation that moves it in place. And the progress indicator constantly shrinks and grows as needed.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But rendering one animated SVG at 60 FPS is already quite taxing, and synchronizing three undulation animations would be a headache. Luckily plain HTML saves the day again with SVG symbols.&lt;br&gt;&lt;/p&gt;&lt;p&gt;In an SVG you can define any set of paths or shapes as a symbol and give it an ID. Then you can render the same symbol in any other SVG in the same HTML with the &lt;i&gt;&lt;em&gt;use&lt;/em&gt;&lt;/i&gt; tag. You can even change the fill and stroke properties on the &lt;i&gt;&lt;em&gt;use&lt;/em&gt;&lt;/i&gt; tag to get different versions of the same shape. And when you update the shape in the symbol it immediately reflects in all other SVGs using that symbol.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- Defines a symbol "foo" as a circle --&amp;gt;&lt;br&gt;&amp;lt;svg xmlns="http://www.w3.org/2000/svg"&amp;gt;&lt;br&gt;  &amp;lt;symbol id="foo" viewBox="0 0 100 100"&amp;gt;&lt;br&gt;    &amp;lt;circle cx="50" cy="50" r="50" /&amp;gt;&lt;br&gt;  &amp;lt;/symbol&amp;gt;&lt;br&gt;&amp;lt;/svg&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- Renders a red circle with a black stroke --&amp;gt;&lt;br&gt;&amp;lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"&amp;gt;&lt;br&gt;  &amp;lt;use href="#foo" fill="red" stroke="black" stroke-width="2"&amp;gt;&amp;lt;/use&amp;gt;&lt;br&gt;&amp;lt;/svg&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- Renders a blue circle --&amp;gt;&lt;br&gt;&amp;lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"&amp;gt;&lt;br&gt;  &amp;lt;use href="#foo" fill="blue"&amp;gt;&amp;lt;/use&amp;gt;&lt;br&gt;&amp;lt;/svg&amp;gt;&lt;br&gt;&lt;/pre&gt;&lt;p&gt;With a symbol I just have to animate one SVG and then I can reuse the same animated shape in any other SVG on the same page. &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/helpers/circle_helper.rb#L16"&gt;I've created a helper to make things more readable.&lt;/a&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;circle_id = dom_id(@exercise, :circle)&lt;br&gt;&lt;br&gt;# Renders an animated circle as a symbol with the id circle_id&lt;br&gt;animated_circle(symbol: circle_id, class: "hidden") +&lt;br&gt;# Renders the minimum indicator&lt;br&gt;animated_circle(use: circle_id, class: "absolute inset-0 z-30 w-full select-none", stroke: "black", stroke_width: 2, name: "exercise-circle-min", data: { exercise_target: "minCircle" }) +&lt;br&gt;# Renders the progress indicator&lt;br&gt;animated_circle(use: circle_id, class: "absolute inset-0 z-20 w-full select-none", data: { exercise_target: "progressCircle" }) +&lt;br&gt;# Renders the maximum indicator&lt;br&gt;animated_circle(use: circle_id, class: "absolute inset-0 z-10 w-full select-none opacity-10", name: "exercise-circle-max", data: { exercise_target: "maxCircle" })&lt;br&gt;&lt;/pre&gt;&lt;p&gt;With that out of the way I now have to animate the progress circle. I read about the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API"&gt;Web Animations API&lt;/a&gt; a few weeks ago and it seemed like just the right tool for the job.&lt;br&gt;&lt;/p&gt;&lt;p&gt;It was my first time using that API and I think I hit every edge case there is with it.&lt;br&gt;Long story short, don't use fill forwards and instead manually set your desired state with the &lt;i&gt;&lt;em&gt;onfinish&lt;/em&gt;&lt;/i&gt; callback. For some reason, fill forwards applied a new CSS rule every time an animation ran. After a few minutes I had a hundred animation style blocks in my Inspection window, all overriding each other. Also, remove any transitions on the animated element as that will make the animation wonky in Firefox (but works fine everywhere else) - the browser will handle the transition for you.&lt;br&gt;&lt;/p&gt;&lt;p&gt;After I figured that out, the rest was straightforward&lt;/p&gt;&lt;pre data-language="js"&gt;async function inhale(duration, element) {&lt;br&gt;  return new Promise(resolve =&amp;gt; {&lt;br&gt;    const animation = this.progressCircleTarget.animate([&lt;br&gt;      { transform: `scale(0.25)` },&lt;br&gt;      { transform: `scale(1.0)` }&lt;br&gt;    ], {&lt;br&gt;      duration: duration * 1000,&lt;br&gt;      easing: "linear"&lt;br&gt;    })&lt;br&gt;&lt;br&gt;    animation.onfinish = () =&amp;gt; {&lt;br&gt;      this.progressCircleTarget.style.transform = `scale(1.0)`&lt;br&gt;      resolve()&lt;br&gt;    }&lt;br&gt;  }&lt;br&gt;}&lt;br&gt;&lt;br&gt;async function exhale(duration, element) {&lt;br&gt;  return new Promise(resolve =&amp;gt; {&lt;br&gt;    const animation = this.progressCircleTarget.animate([&lt;br&gt;      { transform: `scale(1.0)` },&lt;br&gt;      { transform: `scale(0.25)` }&lt;br&gt;    ], {&lt;br&gt;      duration: duration * 1000,&lt;br&gt;      easing: "linear"&lt;br&gt;    })&lt;br&gt;&lt;br&gt;    animation.onfinish = () =&amp;gt; {&lt;br&gt;      this.progressCircleTarget.style.transform = `scale(0.25)`&lt;br&gt;      resolve()&lt;br&gt;    }&lt;br&gt;  }&lt;br&gt;}&lt;br&gt;&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/exercise_controller.js"&gt;Now I just had to pass the exercise steps to the Stimulus controller, and run the animation&lt;/a&gt;. This just meant that I had to define a &lt;i&gt;&lt;em&gt;steps&lt;/em&gt;&lt;/i&gt; value of type Array and serialize the steps as JSON on the HTML element.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Sound&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;With the animation done I wanted to also add an audio cue that you should breathe in or out.&lt;br&gt;&lt;/p&gt;&lt;p&gt;At first I thought that the simplest way to add sound was to play some sound file - like a gong or a flute - at different playback speeds for different durations, and pitch it down to cue and exhale.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This turned out to be a dead end. I could change the playback speed but at 0.5x playback the sound of a gong starts to sound like Hell's bells.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I remembered the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API"&gt;Web Audio API&lt;/a&gt; with which you can generate sounds. &lt;a href="https://www.youtube.com/watch?v=OAsTq5SKnEw"&gt;I found a nice tutorial that explains how to make a bell sound&lt;/a&gt;, but I was out of my depth here and decided to reach for an LLM so that I don't run out of time.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I created a scaffold class with the interfaces I wanted to have and asked ChatGPT to implement the class so that it generates "a nice and relaxing sound that inspires breathing in a mindfulness app I'm working on, kind of like Endel or Headspace". &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/models/sound_player.js"&gt;After a bit of back and forth I had something that I thought was decent&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But it didn't work on my iPhone... Turns out that Apple disables Web Audio playback if your ringer is set to silent, even though the volume is up - odd choice, but ok.&lt;/p&gt;&lt;p&gt;You can listen to the sound at &lt;a href="https://simpliki.app/"&gt;simpliki.app&lt;/a&gt;, just pick any exercise and start it. If you are on an iPhone be sure to set your phone &lt;i&gt;&lt;em&gt;OFF&lt;/em&gt;&lt;/i&gt; silent.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Scroll snapping&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;As I was working on the breathing animation I noticed that I forgot to record if you are supposed to breathe with your nose or mouth, so I had to go back, add a migration, and update the fixtures.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Then I thought that showing a short description about the exercise would be nice so I added ActionText, and a &lt;a href="https://github.com/monorkin/simpliki/blob/main/test/fixtures/action_text/rich_texts.yml"&gt;few fixtures for it&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="shell"&gt;bin/rails action_text:install&lt;/pre&gt;&lt;p&gt;But now that the exercise page could have an overflow and users would have to scroll I wanted the scrollbar to snap if you were close to the top of the screen. That way the exercise circle would always be in focus and you can't accidentally scroll away.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Once again HTML, or rather CSS, saves the day - this time with scroll-snap.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I just added the &lt;a href="https://tailwindcss.com/docs/scroll-snap-type"&gt;&lt;i&gt;&lt;em&gt;snap-y&lt;/em&gt;&lt;/i&gt;&amp;nbsp;and &lt;i&gt;&lt;em&gt;snap-mandatory&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; Tailwind classes to the container, and &lt;a href="https://tailwindcss.com/docs/scroll-snap-align"&gt;&lt;i&gt;&lt;em&gt;snap-start&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; to the container of the exercise circle - that's it. Now the browser snaps to the top when you scroll near the top of the page.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81ODE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a8de97e98d65fbadea70746aa4cee57024622f06" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgxLCJwdXIiOiJibG9iX2lkIn19--e76c984adb464068f8b6189c221ab2bba026f06a/Screencast%20From%202025-04-23%2017-30-35.mp4" filename="Screencast From 2025-04-23 17-30-35.mp4" filesize="1003550" width="850.0" height="1512.0" previewable="true" presentation="gallery" caption="Demo of the page snapping to the top when you scroll near the top"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgxLCJwdXIiOiJibG9iX2lkIn19--e76c984adb464068f8b6189c221ab2bba026f06a/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2017-30-35.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgxLCJwdXIiOiJibG9iX2lkIn19--e76c984adb464068f8b6189c221ab2bba026f06a/Screencast%20From%202025-04-23%2017-30-35.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the page snapping to the top when you scroll near the top
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Full text search&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Since I had four hours left at this point I decided to add full-text search to the index page.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://www.teloslabs.co/post/full-text-search-with-rails-and-sqlite"&gt;This too was surprisingly easy thanks to an article by Sergio Alvarez&lt;/a&gt;. All I needed to do was to create a special kind of virtual table&lt;/p&gt;&lt;pre data-language="ruby"&gt;class AddFullTextSearchToExercises &amp;lt; ActiveRecord::Migration[8.0]&lt;br&gt;  def change&lt;br&gt;    create_virtual_table :exercise_full_text_search_vectors, :fts5, %w[name description]&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Populate it and then query it. &lt;a href="https://github.com/monorkin/simpliki/blob/main/app/models/exercise/searchable.rb"&gt;I created a concern under the Exercise class&lt;/a&gt; that does that. That way the search logic is nicely self-contained.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# frozen_string_literal: true&lt;br&gt;&lt;br&gt;class Exercise&lt;br&gt;  module Searchable&lt;br&gt;    extend ActiveSupport::Concern&lt;br&gt;&lt;br&gt;    DEFAULT_LIMIT = 25&lt;br&gt;    VECTOR_TABLE_NAME = FullTextSearchVector.table_name&lt;br&gt;&lt;br&gt;    included do&lt;br&gt;      has_one :full_text_search_vector, foreign_key: :rowid, dependent: :destroy&lt;br&gt;&lt;br&gt;      after_save :create_or_update_full_text_search_vector&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    class_methods do&lt;br&gt;      def search(query)&lt;br&gt;        return all if query.blank?&lt;br&gt;&lt;br&gt;        where("#{VECTOR_TABLE_NAME} MATCH ?", escape_fts_match_value(query))&lt;br&gt;          .joins(:full_text_search_vector)&lt;br&gt;          .order("bm25(#{VECTOR_TABLE_NAME})")&lt;br&gt;          .distinct&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      def escape_fts_match_value(input)&lt;br&gt;        input.to_s&lt;br&gt;          .gsub('"', '""')&lt;br&gt;          .split(/\s+/)&lt;br&gt;          .map { |word| %Q("#{word}") }&lt;br&gt;          .join(" ")&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      def create_or_update_all_full_text_search_vectors&lt;br&gt;        all.find_each(&amp;amp;:create_or_update_full_text_search_vector)&lt;br&gt;      end&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    def create_or_update_full_text_search_vector&lt;br&gt;      params = {&lt;br&gt;        id: id,&lt;br&gt;        name: name,&lt;br&gt;        description: description.to_plain_text&lt;br&gt;      }&lt;br&gt;&lt;br&gt;      sql = if full_text_search_vector&lt;br&gt;        ActiveRecord::Base.sanitize_sql_array(&lt;br&gt;          [&lt;br&gt;            "UPDATE #{VECTOR_TABLE_NAME} SET name = :name, description = :description WHERE rowid = :id",&lt;br&gt;            params&lt;br&gt;          ]&lt;br&gt;        )&lt;br&gt;      else&lt;br&gt;        ActiveRecord::Base.sanitize_sql_array(&lt;br&gt;          [&lt;br&gt;            "INSERT INTO #{VECTOR_TABLE_NAME} (rowid, name, description) VALUES (:id, :name, :description)",&lt;br&gt;            params&lt;br&gt;          ]&lt;br&gt;        )&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      self.class.connection.execute(sql)&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;With that I can do a full-text search across all exercises&lt;/p&gt;&lt;pre data-language="ruby"&gt;Exercise.search("box")&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Pagination&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Now that I had search I wanted to add pagination. &lt;a href="https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs"&gt;Years ago while exploring what Strada/Turbo Native was&lt;/a&gt; before it was released I saw that 37signals has a really neat way of handling pagination.&lt;br&gt;&lt;/p&gt;&lt;p&gt;They have a "pagination" controller that goes and fetches the HTML of the next page, parses it to get the contents of itself from the next page, and then adds that contents to the current page.&lt;br&gt;&lt;/p&gt;&lt;p&gt;No need for special routes or turbo-stream handlers, you just render the paginated page like always and let the controller do the rest. So I tried to recreate that.&lt;br&gt;&lt;/p&gt;&lt;p&gt;First I tried to fetch the next page and parse it using &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMParser"&gt;DOMParser&lt;/a&gt; to get the next page's document. My first stab worked quite well.&lt;/p&gt;&lt;pre data-language="js"&gt;  async #fetchNextPageDocument() {&lt;br&gt;    const url = this.nextPageLinkTarget.href&lt;br&gt;&lt;br&gt;    const response = await fetch(url, {&lt;br&gt;      headers: {&lt;br&gt;        "Accept": "text/html",&lt;br&gt;      }&lt;br&gt;    })&lt;br&gt;&lt;br&gt;    if (response.ok) {&lt;br&gt;      return new DOMParser().parseFromString(await response.text(), "text/html")&lt;br&gt;    } else {&lt;br&gt;      console.error("Failed to fetch next page:", response.status)&lt;br&gt;      return null&lt;br&gt;    }&lt;br&gt;  }&lt;/pre&gt;&lt;p&gt;Then all I had to do was append the children from the new document to the current one&lt;/p&gt;&lt;pre data-language="js"&gt;	const doc = await this.#fetchNextPageDocument()&lt;br&gt;&lt;br&gt;    const newList = doc.querySelector(`[data-${this.identifier}-target="list"]`)&lt;br&gt;&lt;br&gt;    Array.from(newList.children).forEach(child =&amp;gt; {&lt;br&gt;      this.listTarget.appendChild(child.cloneNode(true));&lt;br&gt;    });&lt;br&gt;&lt;br&gt;    const newNextPageLink = doc.querySelector(`[data-${this.identifier}-target="nextPageLink"]`)&lt;br&gt;    if (newNextPageLink) {&lt;br&gt;      this.nextPageLinkTarget.replaceWith(newNextPageLink.cloneNode(true))&lt;br&gt;    } else {&lt;br&gt;      this.nextPageLinkTarget.remove()&lt;br&gt;    }&lt;br&gt;&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/monorkin/simpliki/blob/main/app/javascript/controllers/pagination_controller.js"&gt;And it worked like a charm&lt;/a&gt;. I just render the next page like always and let the controller do the rest.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81ODI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--1663e557b5557d70c4badfb69db4b9ab588a6e35" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgyLCJwdXIiOiJibG9iX2lkIn19--c8135a4f54d0c78ebcf41657a71e9bf980ea09a1/Screencast%20From%202025-04-23%2018-34-33.mp4" filename="Screencast From 2025-04-23 18-34-33.mp4" filesize="85790" width="850.0" height="1512.0" previewable="true" presentation="gallery" caption="Demo of the pagination controller loading in more exercises"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgyLCJwdXIiOiJibG9iX2lkIn19--c8135a4f54d0c78ebcf41657a71e9bf980ea09a1/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2018-34-33.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgyLCJwdXIiOiJibG9iX2lkIn19--c8135a4f54d0c78ebcf41657a71e9bf980ea09a1/Screencast%20From%202025-04-23%2018-34-33.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the pagination controller loading in more exercises
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Auth, create, update &amp;amp; delete&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Since I still had time left I decided to implement CRUD for exercises. &lt;a href="https://guides.rubyonrails.org/security.html#authentication"&gt;Here I decided to use the new auth generator&lt;/a&gt;. It gave me Users and Sessions, all I now needed to do was add views.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I quickly wrote a nested form Stimulus controller, some CSS to style the inputs and called it a day.&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81ODM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--5a971e3440102e62e852be5dbde4e3def5df8940" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgzLCJwdXIiOiJibG9iX2lkIn19--74cf544733f9a4c2354becc90bdc7b556f677b2a/Screencast%20From%202025-04-23%2018-40-17.mp4" filename="Screencast From 2025-04-23 18-40-17.mp4" filesize="1731651" width="850.0" height="1512.0" previewable="true" presentation="gallery" caption="Demo of the sign in process and CRUD actions"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgzLCJwdXIiOiJibG9iX2lkIn19--74cf544733f9a4c2354becc90bdc7b556f677b2a/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/Screencast%20From%202025-04-23%2018-40-17.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTgzLCJwdXIiOiJibG9iX2lkIn19--74cf544733f9a4c2354becc90bdc7b556f677b2a/Screencast%20From%202025-04-23%2018-40-17.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demo of the sign in process and CRUD actions
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;I wanted to share how I built Simpliki because everyone I show it to seems to think that it's some SPA made with the latest technology trend when in reality it's just &lt;a href="https://github.com/monorkin/simpliki/blob/main/Gemfile"&gt;a vanilla Rails app&lt;/a&gt; that uses a few browser APIs.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Modern browsers are capable of a lot that just a few years ago required cutting edge technology and all the complexity that comes with it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But I also wanted to sing the praises of time-boxing your work and working on toy-projects. It helped me develop a healthier way to think and break an old habit.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">This week I went for a coffee with a friend. As we talked the topic of hobbies came up. I mentioned that I was having a lot of fun working on a toy project over the past week, and then I showed it to him.
Him: So you are dabbling in React?
Me: No, this is just Rails.
Him: Which libraries did you...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/41</id>
    <published>2025-03-03T07:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/adversarial-web-services-Vjfkr3DF9LfG"/>
    <title>Adversarial Web Services</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;My first job was at a digital agency. In addition to app development we also hosted the apps that we'd develop for our clients. This allowed us to avoid fiddly managed hosts, and since we ran everything on a beefy bare-metal server we didn't have to manage dozens of machines at different providers. All in all, this was a good setup.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Then, one Friday morning, I came into the office and went to clock in using an internal app. But the app just timed out. We soon noticed that all internal apps and our client's apps were timing out, our bare-metal server was completely unresponsive, and then came the bombshell - both hard drives in the server had failed.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Luckily we had hourly backups. Or so we thought. We found out the hard way that our backup script had a bug that caused it to create empty backups for the past few months. So we had no viable backup to restore from.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;We spent the next 22 hours provisioning a new machine, re-deploying projects to it, recovering databases and files from the old machine and uploading them to the new one. By some miracle we managed to recover everything.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This incident left a sour taste in everybody's mouth. Everything was back to normal, but nobody was confident that it would stay that way for long. We lost confidence in our tools and ourselves. The next week we were told that we’d be abandoning our bare-metal server and would move all our apps to AWS so that we wouldn't have to monitor the hardware and backups were built-in.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Since that Friday, for more than a decade now, I've used AWS at every job I had.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I've spent countless hours working with its services, configuring and misconfiguring them, optimizing them for performance and cost. And in my opinion it just isn't worth it for most people.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;AWS makes things easy, not simple&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Some people think that AWS-hosted services are somehow magical 1-click solutions that you pay for and forget about. But the truth is far from that.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;While you can set up a MySQL/Redis/Kafka instance in just a few easy clicks, after that you have to configure, tweak, monitor and manage that service like you would if you'd host it yourself.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;In other words, the complexity is the same, but the setup is easier.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;In fact, it's even more complex than running your own MySQL/Postgres/Redis/... server because AWS introduces its own arbitrary limitations on what you can and can't do. And on top of that it also introduces new concepts that you'll have to learn and manage - Floating IPs, Load Balancers, CPU credits, Network credits, IOPS credits, EBS credits, VPCs, Security Groups, IAM roles...&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;If you don’t learn these things, you will eventually either encounter a problem you can’t fix or receive an exorbitant bill from AWS.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Probably the best example of this is EC2. You can set up an instance in a minute. And you can probably deploy something to it with minimal knowledge of Linux and/or Docker. But what will you do, for example, if you run out of IOPS credits? Or if Docker refuses to start after a mandatory update? Or your instance suddenly changes its IP address?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Setting up a VPS is extremely easy, but if you don't know Linux, networking and some AWS gotchas, but managing and monitoring the instance is still complex.&lt;/div&gt;&lt;div&gt;Think of it as sending an email. It's simple, all you have to do is open up Gmail, write a message, enter a recipient and hit send. But under the hood what is actually happening is a complex exchange between email servers that involves IMAP, DKIM, SPF, DNS, Spam detection, Spoofing detection, reputation management, firewalls, storage, and more.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I hope that some of the acronyms and concepts of the complexity of email servers are foreign to you because that's my whole point.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;By buying email from Gmail you are giving Google money so that you don't have to deal with the complexities of running an email server. But at AWS you are giving Amazon money to get your own server. They will set it up for you, but then it's up to you to deal with the complexity of running that server.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Take everything with a grain of salt&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;One of the biggest mistakes I ever made was to trust AWS marketing at face value. Their marketing often omits important or inconvenient details.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Figuring out what you are &lt;em&gt;really&lt;/em&gt; buying requires you to read the product's documentation - not one page, not the pricing page, but all of it - &lt;a href="https://old.reddit.com/r/aws/comments/bv70k8/aurora_postgres_disastrous_experience/"&gt;and even then you have to take it with a grain of salt&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Take the incident &lt;a href="https://stanko.io/monitoring-actioncable-GdeaeHfIU4Yk"&gt;I wrote about 2 weeks ago&lt;/a&gt;. At work we use ElastiCache Redis as a PubSub provider for our WebSockets. One day all our devices started disconnecting randomly. After weeks of investigation it turned out that we hit a CPU cap even though the instance claimed it was at 10% CPU during the incident. This happened because we were using a node type that's capped at 10% CPU which is, conveniently and vaguely, only mentioned in the documentation while the pricing page says that we get 2 full vCPUs.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Or how API Gateway offers WebSockets. &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html#apigateway-execution-service-websocket-limits-table"&gt;But you can't have a connection that lasts longer than 2h&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-route-keys-connect-disconnect.html#apigateway-websocket-api-routes-about-disconnect"&gt;and you can't reliably know if a client has disconnected&lt;/a&gt;.&lt;/div&gt;&lt;div&gt;Or how Aurora offers unprecedented speed and infinite storage. &lt;a href="https://aws.amazon.com/about-aws/whats-new/2022/10/amazon-aurora-postgresql-14-4-version/"&gt;But it might corrupt your database&lt;/a&gt;. &lt;a href="https://plaid.com/blog/exploring-performance-differences-between-amazon-aurora-and-vanilla-mysql/"&gt;And high load on a read-replica degrades the primary's performance negating the benefit of having the replica&lt;/a&gt;.&lt;/div&gt;&lt;div&gt;There are many more examples.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;But the gist of the story is that you have to carefully navigate AWS' documentation labyrinth - and it really is a labyrinth - to gather as much information as you can before you commit to a purchase. And even then you aren't 100% sure what you've purchased or how much it will cost you in the end.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Some might say that this is a "skill issue", or just "RTFM". But I think that people who say this are either stuck in an inferiority complex or are just coping.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Imagine if any other business sold stuff like AWS does. Let's take a restaurant as an example.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;You go to a restaurant and want to order a pizza. The waiter walks up to you and tells you what pizzas they have. There are a few you like so you ask for the price and the waiter brings you the menu. You see that the pizza you like costs 15€ for a large so you order it. Then a short while later the waiter brings you a single slice of the pizza. You ask him where's the rest of the pizza and he points out that on the back of the menu it says that some pizzas come with limitations mentioned in the "limitation list". Then he brings you the limitation list which states that the pizza you ordered is served per slice.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;You'd probably never go to that place ever again.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Sometimes it can feel like racketeering&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;After using AWS for a while I've noticed that it isn't as elastic as it claims to be, and somehow that feels intentional.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Let's take the WebSocket disconnect incident again. We were using an instance that has 2 vCPUs but can only use 10% consistently and we decided to upgrade to an instance that can use 100% of the CPU available to it.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;That’s a 10x increase in compute, so we expected a 10x increase in price - and that’s exactly what we got. The monthly price for our instance went up from about 10€ to about 100€.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;But we soon learned that, while we did get 10x the CPU, we got only 1/5 of the bandwidth.&lt;br&gt;&lt;br&gt;So we upgraded to the first node type that offered the same bandwidth as before and our monthly cost rose to 200€ per month.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;em&gt;That's a 20x increase in price for 10x the compute.&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Never mind the exorbitant price for what is essentially a managed EC2 instance that otherwise costs 60€. What bugs me the most here is that we're talking about a virtual machine, not a bare-metal server. You can give it as much CPU, memory and bandwidth as you want.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Nearly all AWS services give you a predefined list of instance types to choose from - this is not just an ElastiCache issue.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I understand why smaller hosting providers make you choose from predefined instance types - they simply don't have the hardware to allow anybody to choose what they need. But a hyperscaler like AWS? One that calls their VPS offering Elastic Cloud Compute?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Another thing I noticed is that some services have conveniently set, completely arbitrary, limitations that seem to exist only to make AWS more money.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;For instance, API Gateway imposes a maximum duration on a WebSocket of 2h and will close any idle connections after 10min. But at the same time AWS charges you per WebSocket message in addition to each minute each client was connected, and most applications will need at least 1 message after a reconnect.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;If you have 50k clients you'll pay AWS 200€ every month just to keep them connected to a WebSocket 24/7. For comparison, a 60€ EC2 instance can handle more clients than that with no limitations.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;To go back to the restaurant analogy. Imagine if you bought a Pizza at a restaurant and the waiter starts bringing you your pizza one slice at a time, because they decided to do so for whatever reason. Then, at the end of the meal, they charge you a delivery fee for every individual slice on top of the price of the pizza.&lt;/div&gt;&lt;div&gt;The pizza would have to be out of this world for me to come back to that restaurant.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;When you run into such pricing practices AWS doesn't feel like a partner helping you build stuff anymore; it feels like an adversary trying to squeeze every penny out of you.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Serfs and sovereigns&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;My biggest gripe with AWS are its proprietary services. Some of them are great and novel while others are OK equivalents of existing open-source services.&lt;/div&gt;&lt;div&gt;When it comes to great and novel services, the first thing that comes to my mind is S3.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;You can get S3-compatible object storage at a dozen places today - &lt;a href="https://www.digitalocean.com/products/spaces"&gt;DigitalOcean Spaces&lt;/a&gt;, &lt;a href="https://www.backblaze.com/cloud-storage"&gt;Backblaze B2&lt;/a&gt;, &lt;a href="https://www.cloudflare.com/developer-platform/products/r2/"&gt;Cloudflare R2&lt;/a&gt;, &lt;a href="https://www.hetzner.com/storage/object-storage/"&gt;Hetzner's&lt;/a&gt;, &lt;a href="https://www.ovhcloud.com/en/public-cloud/object-storage/"&gt;OVH's&lt;/a&gt; and &lt;a href="https://www.scaleway.com/en/object-storage/"&gt;Scaleway's Object Storage&lt;/a&gt;, or open-source &lt;a href="https://github.com/deuxfleurs-org/garage"&gt;Garage&lt;/a&gt; and &lt;a href="https://github.com/minio/minio"&gt;MinIO&lt;/a&gt; just to name a few - but that wasn't always the case.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;A little over 10 years ago S3 reigned supreme when it came to storage. The only real alternative to it was to either store the files locally on your server or using a network attached drive, both of which required some skill to scale and manage - at least compared to S3.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Back then, it was common for people to e.g. use a managed host but still have an AWS account just to get access to S3. Problem was, we were completely dependent on AWS for object storage. If they raised prices we had no real option but to pay. There was no contingency if S3 had an incident you just had to deal with the customer backlash.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;When other S3-compatible object storage providers started popping up not only did prices come down but you could suddenly choose a provider that suits your need - no ingress fees, no egress fees, cheaper long-term storage, faster storage, HIPPAA-compliant storage, etc. You could mix and match providers to suit your need, or use multiple as a contingency.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;But most importantly, any code I write today that fetches or uploads files to S3 will work just as well anywhere. This means that, if I don't like a price hike or a term change from my current object storage provider, I can just pick up my stuff and move to another provider that offers more favorable terms.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;When it comes to S3 - 10 years ago we were basically serfs, today we are sovereign and I don't miss the old days one bit.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;There are other great AWS services - like DynamoDB - but I avoid them in favor of open-source alternatives just because I don't want to find myself locked in and having to accept any term change to avoid spending days switching to something else.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Competence&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The incident that happened at my first job wasn't caused by our hosting provider, it was caused by us. Our hosting provider at the time gave us all the tools and services we needed to prevent that incident from happening, we just didn't know how to use them. It was our incompetence that caused the incident.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;As a knee-jerk reaction we migrated to AWS chasing a promise, or rather a dream, that it will somehow save us from our incompetence - it didn't. All it did was mask that incompetence by making things easy to set up, but our old problems all eventually reoccurred.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Only by learning to run my own server through learning Linux, Docker, networking, monitoring, backups and server security did I manage to avoid making the same mistakes - not just with AWS but at any host.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;And that's the take-away here - AWS, or any other managed service provider, won't save you from having to learn how to manage that service. They will give you monitoring, but you still have to know what the metrics mean and what's acceptable for you. They will give you hardware, but you have to know what's enough for you. They will give you firewalls and other networking tools, but you still have to configure them for your use-case.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;All you need to learn this stuff is a Virtual Machine or a Raspberry Pi and some time. Set up a small server and try to configure a firewall, install a database, run an app, upgrade the database. If you fail, and you probably will, just re-install and try again.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;One of the best decisions I ever made was learning how to self-host the services I use at work. It was difficult at first - as going from 0 to 1 always is - but it made me much more confident and better at my job. I now understand not only how to operate these services but also how to optimize my code to make the most of them. And best of all is that these skills carry over to any hosting provider, project and job.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;If you are willing to learn, I think you will be better off renting a few VPS instances or bare-metal machines from the likes of &lt;a href="https://www.digitalocean.com/"&gt;DigitalOcean&lt;/a&gt;, &lt;a href="https://www.hetzner.com/"&gt;Hetzner&lt;/a&gt;, &lt;a href="https://www.ovhcloud.com/en-gb/"&gt;OVH&lt;/a&gt;, and &lt;a href="https://www.scaleway.com/en/"&gt;Scaleway&lt;/a&gt;. The predictable pricing, and lack of AWS BS, will save you time and money. Or if you don't want to learn all this, then go with a hosting platform like &lt;a href="https://www.heroku.com/"&gt;Heroku&lt;/a&gt; or &lt;a href="https://fly.io/"&gt;Fly&lt;/a&gt;.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">My first job was at a digital agency. In addition to app development we also hosted the apps that we'd develop for our clients. This allowed us to avoid fiddly managed hosts, and since we ran everything on a beefy bare-metal server we didn't have to manage dozens of machines at different...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/40</id>
    <published>2025-01-27T10:26:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/monitoring-actioncable-GdeaeHfIU4Yk"/>
    <title>Monitoring ActionCable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;A few weeks ago I was woken up with the following message "Hey, devices are randomly disconnecting from WebSockets can you come online and help?".&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Usually, when a widespread problem like this occurs out of the blue, my first goal is to make the app mostly operational again and my second goal is to fix the cause of the problem. To do that I usually go over each component that could be causing this to check if it's functioning correctly and then drill down on any components that aren't.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step one, identify which components could be at fault.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;We use Rails' ActionCable for WebSockets. I've done a deep dive into ActionCable &lt;a href="https://stanko.io/deconstructing-action-cable-DC7F33OsjGmK"&gt;in a previous article&lt;/a&gt; if you'd like to read more about it.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;ActionCable, except for the Ruby process itself, depends on Redis (in my case) and on the database(s) that the app uses.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step two, prioritize.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Since I knew that the devices were randomly disconnecting I assumed that either app containers were restarting for whatever reason, or that something was causing ActionCable's worker pool to lag which then caused missed heartbeats.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This means that the Ruby process is the most likely culprit, then the database, and finally Redis.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step three, check each components.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I checked Grafana to see what's going on with the app containers and saw that CPU was elevated but wasn't exceeding 80%, while memory didn't go above 70%, and the auto-scaler was adding more containers as needed. Everything was in order given that a reconnect storm was going on.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Then I checked Elastic APM to see what was going on with /cable. But the metrics were phenomenal - sub-millisecond response times and 100% successful responses. Fantastic numbers for a reconnect storm.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Next I checked the database. CPU and memory were at average levels, there were no slow queries. Everything was fine.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Lastly, I checked Redis. To my surprise, here too, everything was fine. The CPU never exceeded 10% and memory was at 5%.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;What was going on?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step four, stabilize the app.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Since Redis and the database were fine, in an Hail Mary attempt, I scaled-out the number of app containers to stop the disconnects. Surprisingly, this worked... This pointed a spotlight at the application code and ActionCable. They must have been the faulty component since the scale-out fixed the issue - but how?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step five, find the root cause.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;At that point I realized that I had no clue what was going on with our WebSockets after they were established. Elastic APM only captures metrics about the HTTP part of the WebSocket connection, but once it was established I had absolutely no insight into what's going on - how many clients there are, what the latency was like, how many messages were being sent...&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The best way to figure that out was to introduce some kind of monitoring. But what should I monitor and how should I surface those metrics?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I'm not one to reinvent the wheel, so I checked if some tool for monitoring ActionCable already exists. I found out that Sentry's and AppSignal's APM tools monitor ActionCable's action execution times.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Action Execution Time&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Action execution time measures how long it takes for a Channel to process a received message. This metric is important because it can show if you have a slow action which binds up the worker pool for too long and thus lowers throughput and increases latency. It's a nice way to quickly see if everything is alright with ActionCable.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I quickly exposed that metric to Elastic APM using the following code.&lt;/div&gt;&lt;pre&gt;# config/initializers/elastic_apm.rb

ActiveSupport::Notifications.subscribe "perform_action.action_cable" do |event|
  transaction = ElasticAPM.start_transaction "#{event.payload[:channel_class]}##{event.payload[:action]}"
  transaction.start(event.time * 1000.0)
  
  ElasticAPM.end_transaction(event.payload[:exception].blank?)
end
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;That worked!&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;PubSub latency&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I soon noticed that some extra metrics, like the PubSub latency, would be helpful. Knowing the PubSub latency would help me identify problems with the PubSub component of ActionCable. If the latency - the time it takes for a message to be sent to clients since it was broadcast from the app - goes up that means that the PubSub service, in my case Redis, is probably the cause of a problem.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NTY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--194e0b16dc231c888b7d12829014e2901be41864" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU2LCJwdXIiOiJibG9iX2lkIn19--a52bcb95aac5b0a064f98d2d586c70ac6ec2057a/pubsub_latency.png" filename="pubsub_latency.png" filesize="63365" width="793" height="255" previewable="true" presentation="gallery" caption="Diagram that shows what PubSub latency measures"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Diagram that shows what PubSub latency measures" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU2LCJwdXIiOiJibG9iX2lkIn19--a52bcb95aac5b0a064f98d2d586c70ac6ec2057a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/pubsub_latency.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Diagram that shows what PubSub latency measures
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;To measure that latency I have to know how much time has passed since a message was broadcast to when it was received in the ActionCable server. The easiest way to do that is to broadcast a message with the current time in it, then receive it in some channel and calculate the time difference between the time I received the message and the timestamp in the message.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;To send the a message with the current timestamp I can do the following and run it from a cron job every minute&lt;/div&gt;&lt;pre&gt;measurement_payload = {
  sent_at: Time.now.to_f
}

ActionCable.server.broadcast("metrics", measurement_payload)&lt;/pre&gt;&lt;div&gt;To receive the message and calculate the latency I can do this&lt;/div&gt;&lt;pre&gt;stream_from("metrics", proc do |json|
  payload = ActiveSupport::JSON.decode(json)
  sent_at = payload[:sent_at]
  latency = Time.now.to_f - sent_at
  
  Rails.logger.debug "PubSub Latency: #{latency}"
end)&lt;/pre&gt;&lt;div&gt;But where can I put this code and how do I expose the latency?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;After some experimentation I decided to create a stream from every open channel in ActionCable. This sounds pretty bad, and I'd like to find a better way to do this, but it has some upsides and the downsides can be minimized. The upside is that no measurements are performed if there are no clients. The downside is that each client has its own metrics subscription even though only one subscription per server is needed.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;There are many ways to limit the subscription to be one per server and I opted for the simplest one - a mutex. A mutex, aka mutually exclusive lock, allows me to ensure that just one thread is running a piece of code at a time. In Ruby, you'd usually use &lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-synchronize"&gt;Mutex#synchronize&lt;/a&gt; like so:&lt;/div&gt;&lt;pre&gt;# Create the mutex and a counter
mutex = Mutex.new
count = 0

# Defines a proc that prints the value of the counter
# and increments it by 1
work = proc do
  10.times do
    mutex.synchronize do
      sleep 0.1
      puts "count: #{count}"
      count += 1
    end
  end
end

# Spawns 50 threads that all call the proc we just defined
# and then waits for all the threads to finish
50.times.map do
  Thread.new { work.call }
end.to_a.each(&amp;amp;:join)

# Prints the final count after all the threads are done
puts "Final count: #{count}"&lt;/pre&gt;&lt;div&gt;If you now run this code from IRB and look at the output you'd see that the count always goes up by one and that the final count is 500&lt;/div&gt;&lt;pre&gt;...
count: 487
count: 488
count: 489
count: 490
count: 491
count: 492
count: 493
count: 494
count: 495
count: 496
count: 497
count: 498
count: 499
Final count: 500&lt;/pre&gt;&lt;div&gt;But if you remove the mutex, first you'd notice that the code runs much faster, second thing you'd notice is that the output is completely random, sometimes repeats, and change each time you run this&lt;/div&gt;&lt;pre&gt;...
count: 487
count: 488
count: 489
count: 490
count: 491
count: 492
count: 493
count: 493
count: 495
count: 496
count: 495
count: 496
count: 494
Final count: 497&lt;/pre&gt;&lt;div&gt;This is because, without the mutex, all the threads try to read and update the counter at the same time. Sometimes two threads read the value at the same time - like 493 in the output above - which results in a value being skipped - because both read 493 and updated it to 494.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;With the mutex, one thread locks the mutex using the synchronize method, updates the counter ten times, and unlocks the mutex, then another thread lock it again, etc.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;That's why with the mutex the code takes longer to complete, each thread runs after the other so the total time is 50 times 10 times 100ms which is 50 seconds. While without the mutex all threads run at the same time so the total time it takes to execute the code is 10 x 100ms or 1 second.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;To guarantee that just 1 subscription will measure the latency I can use &lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-try_lock"&gt;Mutex#try_lock&lt;/a&gt; and &lt;a href="https://rubyapi.org/3.4/o/thread/mutex#method-i-unlock"&gt;Mutex#unlock&lt;/a&gt; instead of Mutex#synchronize. Unlike synchronize which blocks and waits until the mutex unlock, try_lock doesn't block and instead returns true if it acquired the lock or false if it didn't. With try_lock I can tell all threads to try an measure the latency, the thread that locks the mutex then performs the measurement and all others return.&lt;/div&gt;&lt;pre&gt;MUTEX = Mutex.new

def run_unless_already_running(&amp;amp;block)
  lock_acquired = MUTEX.try_lock
  return unless lock_acquired

  block.call
ensure
  MUTEX.unlock if lock_acquired
end

def collect(payload)
  run_unless_already_running do
    latency = Time.now.to_f - sent_at
  
    Rails.logger.debug "PubSub Latency: #{latency}"
  end
end

stream_from("metrics", proc do |json|
  payload = ActiveSupport::JSON.decode(json)
  collect(payload)
end)&lt;/pre&gt;&lt;div&gt;To make things prettier I put all code related to metrics collection into a module&lt;/div&gt;&lt;pre&gt;module ActionCable
  module MetricsCollector
    MUTEX = Mutex.new
    STREAM_NAME = "internal.action_cable.metrics"
    COLLECTION_TRESHOLD = 60.seconds

    cattr_accessor :last_collected_at

    class &amp;lt;&amp;lt; self
      def measure
        ActionCable.server.broadcast(stream_name, measurement_payload)
      end

      def stream_name = STREAM_NAME

      def measurement_payload
        {
          sent_at: Time.now.to_f
        }
      end

      def collect(payload)
        run_unless_already_running do
          next if measurement_already_collected?

          self.last_collected_at = Time.now
          collect_measurement(payload)
        end
      end

      def measurement_already_collected? = last_collected_at&amp;amp;.after?(collection_trashold.ago)

      def collection_trashold = COLLECTION_TRESHOLD

      private

      def run_unless_already_running(&amp;amp;block)
        lock_acquired = MUTEX.try_lock
        return unless lock_acquired

        block.call
      ensure
        MUTEX.unlock if lock_acquired
      end

      def collect_measurement(payload)
        pub_sub_latency = [0, Time.now.to_f - payload.fetch("sent_at", -Float::INFINITY)].max
        Rails.logger.debug "PubSub latency: #{pub_sub_latency}"
      end
    end
  end
end&lt;/pre&gt;&lt;div&gt;Then I changed my cron job to run&lt;/div&gt;&lt;pre&gt;bin/rails runner "ActionCable::MetricsCollector.measure"&lt;/pre&gt;&lt;div&gt;And added the following to &lt;em&gt;app/channels/application_cable/channel.rb&lt;/em&gt;&lt;/div&gt;&lt;pre&gt;module ApplicationCable
  class Channel &amp;lt; ActionCable::Channel::Base
    after_subscribe do
      stream_from(
        ::ActionCable::MetricsCollector.stream_name,
        proc do |json|
          payload = ActiveSupport::JSON.decode(json)
          ::ActionCable::MetricsCollector.collect(payload)
        rescue =&amp;gt; e
          Rails.logger.error("Failed to collect metrics: #{e}")
        end
      )
    end
  end
end&lt;/pre&gt;&lt;div&gt;How do I expose this metric to Elastic APM?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;After some thought I decided I wouldn't. I could hack something together, but I'm shoehorning this metric in and later metrics I flat out don't know how to expose in Elastic AMP. So I decided not to bother with this and figure it out later.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Number of connections&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Since I'm running an IoT platform I have a somewhat constant number of clients connected at all times. This means that by knowing the number of clients at any given moment I can easily see if there is a problem or if I need to scale out.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Luckily, with the module that I've added to measure the PubSub latency it's easy to expose the number of client. All I have to do is change the &lt;em&gt;collect_measurement&lt;/em&gt; method like so&lt;/div&gt;&lt;pre&gt;def collect_measurement(payload)
  pub_sub_latency = [0, Time.now.to_f - payload.fetch("sent_at", -Float::INFINITY)].max
  Rails.logger.debug "PubSub latency: #{pub_sub_latency}"

  connection_count = ActionCable.server.connections.length
  Rails.logger.debug "Connection count: #{connection_count}"
end&lt;/pre&gt;&lt;div&gt;Without that module I'd probably setup a periodic task like so&lt;/div&gt;&lt;pre&gt;module ApplicationCable
  class Channel &amp;lt; ActionCable::Channel::Base
    periodically every: 1.minute do
	  connection_count = ActionCable.server.connections.length
      Rails.logger.debug "Connection count: #{connection_count}"
    end
  end
end
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;The trick to getting the count is to call &lt;em&gt;ActionCable.server.connections.length&lt;/em&gt; from within the server process. If you call it from the Rails console or from a rails runner the count will always return zero as the connections only exist in the server's memory.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Client-Server Latency&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Since I'm running &lt;a href="https://stanko.io/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz"&gt;my own patch of ActionCable that adds PONG messages&lt;/a&gt; in addition to the standard heartbeat PING message, I can easily expose the time it takes for a message to travel from the server over the Internet to a client.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NTU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a0f1d43f2695b54e7c5dbaca383b9d18883561eb" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU1LCJwdXIiOiJibG9iX2lkIn19--42fe47ba68af550493d34f20e35aa8179c046877/client_server_latency.png" filename="client_server_latency.png" filesize="102398" width="1365" height="400" previewable="true" presentation="gallery" caption="Diagram that shows what Client-Server latency measures"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Diagram that shows what Client-Server latency measures" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU1LCJwdXIiOiJibG9iX2lkIn19--42fe47ba68af550493d34f20e35aa8179c046877/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/client_server_latency.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Diagram that shows what Client-Server latency measures
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This metric isn't useful for regular web apps as it's non actionable - you can't fix your client's Internet. But it's useful for apps, like mine, where you have control over clients and want to have them connected 24/7.&lt;/div&gt;&lt;pre&gt;# Stock Rails 8 doesn't have this notificaiton
ActiveSupport::Notifications.subscribe "client_latency.action_cable" do |_name, _start, _finish, _id, payload|
  Rails.logger.debug("Client latency: #{payload[:value]}")
end
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;Clients will start disconnecting if this latency exceeds six seconds. If the median latency exceeds a few seconds this indicates a widespread Internet issue &lt;em&gt;(which is more common in the US than I though it would be)&lt;/em&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Now that I have all these metrics I have to expose them somehow.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I decided to talk to a coworker from the DevOps team about how they collect and expose metrics from our machines and he told me that they use &lt;a href="https://prometheus.io/docs/introduction/overview/"&gt;Prometheus&lt;/a&gt; and that he could configure it to collect metrics from ActionCable.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;But what is Prometheus?&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Prometheus is an open-source monitoring and alerting system - it's a service that collects metrics, allows you to query them, and to configure alerts when they exceed a threshold. It's a bit different from the SaaS monitoring solutions because it scrapes metrics from your application instead of having your application push metrics to it.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NTQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--a6bfd2e3f12355be02cab59dfa36d4f9c9b8dce4" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU0LCJwdXIiOiJibG9iX2lkIn19--13e3b832adbd7bbb13a0816cc8a5a7d3f1e6435c/prometheus.png" filename="prometheus.png" filesize="161878" width="1420" height="610" previewable="true" presentation="gallery" caption="Diagram that explains the difference between Prometheus and other APM services"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Diagram that explains the difference between Prometheus and other APM services" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTU0LCJwdXIiOiJibG9iX2lkIn19--13e3b832adbd7bbb13a0816cc8a5a7d3f1e6435c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/prometheus.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Diagram that explains the difference between Prometheus and other APM services
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Usually, you'd expose a /metrics endpoint that renders all the metrics the application has collected in a format that Prometheus can parse.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I wasn't sure how to do that at first, but I remember reading &lt;a href="https://dev.37signals.com/kamal-prometheus/"&gt;an article on Basecamp's dev blog and it mentioned a gem called Yabeda&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;a href="https://github.com/yabeda-rb/yabeda"&gt;Yabeda&lt;/a&gt; is a framework for monitoring Ruby applications. Using it you can measure, or report measurements, to various monitoring services like Datadog, NewRelic, Honeybadger, AWS CloudWatch, Prometheus, and many others.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;In a nutshell, I can report my measurements to Yabeda and then it handles exposing those metrics to various different monitoring services - like Prometheus - for me.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;According to the documentation, before I can use Yabeda I have to configure it which means that I have to define all the metrics that I'm going to expose. Yabeda supports a handful of types of metrics - counters, gauges, and histograms - so let me explain those first.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;A counter is a number that can only ever go up by some amount. E.g. the number of ActiveJob jobs processed is a counter because the metric is a number that can only ever increase. But the number of connections to ActionCable isn't a counter since the number goes up when more clients connect and goes down when clients disconnect.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;To represent numbers that can go both up and down as a metric we need a gauge. Gauges represent a metric that can be any number at any time.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Finally, a histogram represents a distribution of values. It tells you how many measurements you've got in a certain range called a bucket. Histograms are usually represented by a set of counters and gauges - one counter for each bucket, one counter for the number of measurements, and one gauge for the sum of all measurements. This sounds a bit complicated, but it allows you to calculate the average, median, and other distribution metrics quickly and without having to store every single measurement somewhere. Usually you'd use a histogram to represent time measurements like response times.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Defining these metrics is fairly straight forward&lt;/div&gt;&lt;pre&gt;# config/initializers/yabeda.rb

Yabeda.configure do
  group :action_cable do
    histogram :client_server_latency do
      comment "The time it takes for a message to travel via the Internet to a client"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    histogram :pubsub_latency do
      comment "The time it takes for a message to travel through the PubSub service"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    histogram :action_execution_duration do
      comment "The time it takes to perform an invoked action"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    gauge :connection_count, comment: "Number of open WebSocket connections"
  end
end&lt;/pre&gt;&lt;div&gt;To report a metric I have to build a chain of method calls consisting of the group names of the metric I want to report, then the metric's name, and finally call &lt;em&gt;measure&lt;/em&gt; on that. The measure method accepts either one argument and a block or two arguments. The first argument is a Hash of tags to add to the measurement, I'll explain those later. The second is the measurement itself. Without the second argument, Yabeda will execute the block, measure it's execution time, and report that.&lt;/div&gt;&lt;pre&gt;Yabeda.action_cable.client_server_latency.measure({}, 0.123)
# or
Yabeda.action_cable.client_server_latency.measure({}) do
  sleep 0.123 # do some work
end&lt;/pre&gt;&lt;div&gt;I can now update all ActiveSupport Notification subscriptions to report to Yabeda, and I can move them all to Yabeda's initializer.&lt;/div&gt;&lt;pre&gt;# config/initializers/yabeda.rb

Yabeda.configure do
  group :action_cable do
    histogram :client_server_latency do
      comment "The time it takes for a message to travel via the Internet to a client"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    histogram :pub_sub_latency do
      comment "The time it takes for a message to travel through the PubSub service"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    histogram :action_execution_duration do
      comment "The time it takes to perform an invoked action"
      unit :seconds
      buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
    end

    gauge :connection_count, comment: "Number of open WebSocket connections"
  end
end

ActiveSupport::Notifications.subscribe "perform_action.action_cable" do |event|
  name = "#{event.payload[:channel_class]}##{event.payload[:action]}"
  duration = event.duration / 1000.0 # convert ms to seconds
  
  Yabeda
    .action_cable
    .action_execution_duration
    .measure({ action: action }, duration) # I've used a tag here
end

ActiveSupport::Notifications.subscribe "client_latency.action_cable" do |_name, _start, _finish, _id, payload|
  client_server_latency = payload[:value]
  Yabeda
    .action_cable
    .client_server_latency
    .measure({}, client_server_latency)
end&lt;/pre&gt;&lt;div&gt;You might have noticed that I've used a tag when reporting the action execution time. You can think of tags as groups of measurements. Instead of collecting the action execution time of all actions in ActionCable, using tags I'll collect the action execution time of each individual action. That way I can easily pin point which action has the highest duration, but I can still get the average execution time by finding the average of all the tagged measurements.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Without tags I'd have to define dozens of histograms - one for each action. But with tags I can define one histogram and let Yabeda group my measurements for me.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I have to update the metrics collector too&lt;/div&gt;&lt;pre&gt;module ActionCable
  module MetricsCollector
    MUTEX = Mutex.new
    STREAM_NAME = "internal.action_cable.metrics"
    COLLECTION_TRESHOLD = 60.seconds

    cattr_accessor :last_collected_at

    class &amp;lt;&amp;lt; self
      def measure
        ActionCable.server.broadcast(stream_name, measurement_payload)
      end

      def stream_name = STREAM_NAME

      def measurement_payload
        {
          sent_at: Time.now.to_f
        }
      end

      def collect(payload)
        run_unless_already_running do
          next if measurement_already_collected?

          self.last_collected_at = Time.now
          collect_measurement(payload)
        end
      end

      def measurement_already_collected? = last_collected_at&amp;amp;.after?(collection_trashold.ago)

      def collection_trashold = COLLECTION_TRESHOLD

      private

      def run_unless_already_running(&amp;amp;block)
        lock_acquired = MUTEX.try_lock
        return unless lock_acquired

        block.call
      ensure
        MUTEX.unlock if lock_acquired
      end

      def collect_measurement(payload)
        pub_sub_latency = [0, Time.now.to_f - payload.fetch("sent_at", -Float::INFINITY)].max
        Yabeda.action_cable.pub_sub_latency.measure({}, pub_sub_latency)

		connection_count = ActionCable.server.connections.length
		Yabeda.action_cable.connection_count.set({}, connection_count)
      end
    end
  end
end&lt;/pre&gt;&lt;div&gt;Now that my metrics are in Yabeda, I have to somehow expose them to Prometheus. For that I've added the &lt;a href="https://github.com/yabeda-rb/yabeda-prometheus-mmap"&gt;yabeda-prometheus-mmap gem&lt;/a&gt; and mounted its route&lt;/div&gt;&lt;pre&gt;# config/routes.rb

Rails.application.routes.draw do
  mount Yabeda::Prometheus::Exporter, at: "/metrics"
  # ...
end&lt;/pre&gt;&lt;div&gt;There is also &lt;a href="https://github.com/yabeda-rb/yabeda-prometheus"&gt;yabeda-prometheus&lt;/a&gt;, if you are running a single worker server these two are effectively the same. But if you are running Puma with multiple workers, or SolidQueue with multiple workers then the mmap version of the gem will be much faster.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Additionally, when running multiple workers, you probably also want to add &lt;em&gt;"aggregation: :sum"&lt;/em&gt; to all gauges. Without that option, if a worker fails and gets restarted, you'll get very wonky metrics because the gauges will get counted twice with the old worker's gauge being stuck at whatever value it was before the worker crashed.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Now, with the route mounted I can connect to my ActionCable server and then open &lt;em&gt;/metrics&lt;/em&gt; in a separate browser window to see the output&lt;/div&gt;&lt;pre&gt;...
# HELP action_cable_action_execution_duration_seconds Multiprocess metric
# TYPE action_cable_action_execution_duration_seconds histogram
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="+Inf"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.005"} 101
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.01"} 559
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.025"} 2044
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.05"} 2174
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.1"} 2181
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.25"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="0.5"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="1"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="10"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="2.5"} 2189
action_cable_action_execution_duration_seconds_bucket{environment="development",action="ChatChannel#post_message",le="5"} 2189
action_cable_action_execution_duration_seconds_count{environment="development",action="ChatChannel#post_message"} 2189
action_cable_action_execution_duration_seconds_sum{environment="development",action="ChatChannel#post_message"} 34.04095268249512
...
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;That's my action execution duration. It works!&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;My coworker had already configured Prometheus to scrape that endpoint every minute or so and the metrics appeared in Prometheus within a few minutes.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Analyzing the metrics&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Now that I had all the metrics somewhere where I could query them I had to make them easy to analyze. I've decided to go with &lt;a href="https://grafana.com/"&gt;Grafana&lt;/a&gt; as we already had an instance to monitor our infrastructure.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Grafana allowed me to take this set of metrics and turn them into graphs that are easy to parse. First, I exposed the number of connections. The graph now clearly shows how many clients were connected at each minute.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Mzc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--ad287bbf897327b67df708d3774382503900d4da" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTM3LCJwdXIiOiJibG9iX2lkIn19--0ee1ba9e321854baef260645260da3ff48ca5873/Pasted%20image%2020250127090243.png" filename="Pasted image 20250127090243.png" filesize="54868" width="2755" height="1338" previewable="true" presentation="gallery" caption="The number of connections to ActionCable plotted over time"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The number of connections to ActionCable plotted over time" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTM3LCJwdXIiOiJibG9iX2lkIn19--0ee1ba9e321854baef260645260da3ff48ca5873/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127090243.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The number of connections to ActionCable plotted over time
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Next, I plotted the action execution latency. This is a bit more involved just because I wanted to plot the average, median, 95th percentile and 99th percentile.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The average is important for scaling as it's used in &lt;a href="https://en.wikipedia.org/wiki/Little%27s_law"&gt;Little's law&lt;/a&gt; to determine the number of workers necessary to handle the load. The median is a better representation of what most clients experience as it represents the latency of 50% of all clients. The 95th and 99th percentile represent the worst cases - the latency experienced by 95% and 99% of all clients.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Mzk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--07f25fbb533725fa9494a8b1f359a701fc2ca6ab" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTM5LCJwdXIiOiJibG9iX2lkIn19--20f97b478b1e1561f5ca86586a4f3150dbf5f3ad/Pasted%20image%2020250127090532.png" filename="Pasted image 20250127090532.png" filesize="371778" width="2746" height="1453" previewable="true" presentation="gallery" caption="The action execution latency of all actions plotted over time"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The action execution latency of all actions plotted over time" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTM5LCJwdXIiOiJibG9iX2lkIn19--20f97b478b1e1561f5ca86586a4f3150dbf5f3ad/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127090532.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The action execution latency of all actions plotted over time
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;In addition to plotting the overall latency across all actions I've also plotted the latency per action as a heat-map. Here each rectangle represents 1 minute, red rectangles represent high latency and blue represents low latency. If I see a bright red action here I know that it's causing a problem.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NDA_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--980befb89faf16df2747e72bda37aba2f622e413" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQwLCJwdXIiOiJibG9iX2lkIn19--11537289087b3c4be7b7c23e701168c778d49d71/Pasted%20image%2020250127090930.png" filename="Pasted image 20250127090930.png" filesize="81304" width="2687" height="873" previewable="true" presentation="gallery" caption="A heatmap that breaks down the action execution latency by action over time"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A heatmap that breaks down the action execution latency by action over time" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQwLCJwdXIiOiJibG9iX2lkIn19--11537289087b3c4be7b7c23e701168c778d49d71/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127090930.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A heatmap that breaks down the action execution latency by action over time
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;I've plotted the client-server latency and PubSub latency the same way as the overall action execution duration.&lt;/div&gt;&lt;div&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NDE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--68a63c1efbf40ac73a1e5d00defbd3d24b5d3ff9" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQxLCJwdXIiOiJibG9iX2lkIn19--9f8896b2490510eed76d31ab05d2b430add1377b/Pasted%20image%2020250127090630.png" filename="Pasted image 20250127090630.png" filesize="178518" width="2751" height="1460" previewable="true" presentation="gallery" caption="Client-Server latency plotted over time"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Client-Server latency plotted over time" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQxLCJwdXIiOiJibG9iX2lkIn19--9f8896b2490510eed76d31ab05d2b430add1377b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127090630.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Client-Server latency plotted over time
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;-&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NDI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--6c9d77fc41cee6ffce007d67fcbbc2d2e7c3a31e" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQyLCJwdXIiOiJibG9iX2lkIn19--0b2483049ccaa42285a0e5277d597b0943792f10/Pasted%20image%2020250127090713.png" filename="Pasted image 20250127090713.png" filesize="302760" width="2751" height="1468" previewable="true" presentation="gallery" caption="PubSub latency plotted over time"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="PubSub latency plotted over time" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQyLCJwdXIiOiJibG9iX2lkIn19--0b2483049ccaa42285a0e5277d597b0943792f10/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127090713.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      PubSub latency plotted over time
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;&lt;div&gt;With all these metrics in Grafana I could now focus on finding and fixing the root cause. As the incident had passed and I didn't have any metrics from it so I'd have to wait for another one.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Step six, fixing the root cause.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The app was now stable so I tried a few things that I guessed could be the cause while I waited to see if this would occur again.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Then, after two weeks, the incident happened again!&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I quickly opened Grafana and saw that the PubSub latency was extremely high.&lt;/div&gt;&lt;div&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81NDM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--57cda65c1024a4174adcf40844b67696124a3eb9" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQzLCJwdXIiOiJibG9iX2lkIn19--61c5c2f1196d23d42c8a9c6f2a46e9a8446d1f1c/Pasted%20image%2020250127092424.png" filename="Pasted image 20250127092424.png" filesize="127531" width="767" height="809" previewable="true" presentation="gallery" caption="PubSub latency during the incident"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="PubSub latency during the incident" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTQzLCJwdXIiOiJibG9iX2lkIn19--61c5c2f1196d23d42c8a9c6f2a46e9a8446d1f1c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020250127092424.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      PubSub latency during the incident
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;&lt;div&gt;&lt;em&gt;(the 95th and 99th percentile are capped at 10s because that's the largest bucket I've configured)&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This means that Redis is the culprit. For some reason It takes ages to deliver messages. As a quick fix, I opened the AWS console and scaled up our Redis instance. Within a few minutes the incident cleared up.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I spent the next week investigating ElastiCache Redis and confirmed that it was the root cause of both incidents. I'll go into more detail on this in a subsequent post, but &lt;a href="https://stanko.io/adversarial-web-services-Vjfkr3DF9LfG"&gt;the gist of it is that AWS is a piece of crap&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;Looking back&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I would never have found and fixed this without the monitoring that I've set up.&lt;/div&gt;&lt;div&gt;In the following month I've exposed more metrics like the broadcast and transmission latency, and the number of confirmed and rejected subscriptions.&lt;/div&gt;&lt;pre&gt;ActiveSupport::Notifications.subscribe "broadcast.action_cable" do |_name, start, finish, _id, _payload|
  Yabeda.action_cable.broadcast_duration.measure({}, finish.to_f - start.to_f)
end

ActiveSupport::Notifications.subscribe "transmit.action_cable" do |_name, start, finish, _id, _payload|
  Yabeda.action_cable.transmission_duration.measure({}, finish.to_f - start.to_f)
end

ActiveSupport::Notifications.subscribe "transmit_subscription_confirmation.action_cable" do |_name, start, finish, _id, _payload|
  Yabeda.action_cable.transmission_duration.measure({}, finish.to_f - start.to_f)
  Yabeda.action_cable.confirmed_subscription_count.increment({}, by: 1)
end

ActiveSupport::Notifications.subscribe "transmit_subscription_rejection.action_cable" do |_name, start, finish, _id, _payload|
  Yabeda.action_cable.transmission_duration.measure({}, finish.to_f - start.to_f)
  Yabeda.action_cable.rejected_subscription_count.increment({}, by: 1)
end
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;I don't really need those metrics now, but through this incident I've learned that it's better to have a metric before you need it so I exposed everything I could expose with ActiveSupport Notifications.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;Additionally, I've added a few more gems - like &lt;a href="https://github.com/yabeda-rb/yabeda-puma-plugin"&gt;yabeda-puma-plugin&lt;/a&gt;, &lt;a href="https://github.com/yabeda-rb/yabeda-rails"&gt;yabeda-rails&lt;/a&gt; and &lt;a href="https://github.com/basecamp/yabeda-activejob"&gt;yabeda-activejob&lt;/a&gt; - that expose various metrics to help me pinpoint problems in the future.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">A few weeks ago I was woken up with the following message "Hey, devices are randomly disconnecting from WebSockets can you come online and help?".
Usually, when a widespread problem like this occurs out of the blue, my first goal is to make the app mostly operational again and my second goal is...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/39</id>
    <published>2024-06-03T05:05:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/software-is-made-by-people-gWZri0iMla1q"/>
    <title>Software is made by people</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;The &lt;a href="https://x.com/noahflk/status/1795758603577545035?s=46"&gt;recent Hotwire pop-up drama&lt;/a&gt; has shown me that some have forgotten that software is made by people - people with different backgrounds, different values, different skills, and different flaws.&lt;br&gt;&lt;br&gt;The diversity in opinions and aproaches is what makes modern day software development great. That’s what drives innovation.&lt;br&gt;&lt;br&gt;There are many tribes of people that like to build software in a certain way, that like to use a certain language, that approach problems a certain way, that prefere certan conventions over others. It’s easy to find a tribe and join it, or to start your own.&lt;br&gt;&lt;br&gt;When I started programming professionally I’ve aleardy found my tribes and thought that people in other tribes - &lt;em&gt;especially&lt;/em&gt; those with diametrically opposite beliefs - were just doing software wrong.&lt;br&gt;&lt;br&gt;But I was wrong back then.&lt;br&gt;&lt;br&gt;Today I understand that all tribes in our community formed not because we interpreted something differently, but because we have different values about the same thing - software.&lt;br&gt;&lt;br&gt;We are like visitors at an art gallery looking at an abstract sculpture - each of us sees something different even though we are looking at the same thing.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81Mjk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--8b57919a684d31506fc91b033b4d4cd1201a2f13" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTI5LCJwdXIiOiJibG9iX2lkIn19--d648a0c0994c95e2de4ed25c383685d43fafdbf6/IMG_7410.jpeg" filename="IMG_7410.jpeg" filesize="733203" width="1290" height="781" previewable="true" presentation="gallery" caption="Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTI5LCJwdXIiOiJibG9iX2lkIn19--d648a0c0994c95e2de4ed25c383685d43fafdbf6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/IMG_7410.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Where one person sees a rabbit another will see a duck. There is no one right answer. If it’s a rabbit, or a duck, or both, or neither is dependent on you.&lt;br&gt;&lt;br&gt;HTML-over-the-wire and SPAs, in this context, are two ways of doing the same thing - rendering content in a reactive way. There is no one right answer as to what the best way to do it is. Which one you prefere is up to you.&lt;br&gt;&lt;br&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">The recent Hotwire pop-up drama has shown me that some have forgotten that software is made by people - people with different backgrounds, different values, different skills, and different flaws.

The diversity in opinions and aproaches is what makes modern day software development great. That’s...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/38</id>
    <published>2024-04-01T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B"/>
    <title>Why does Rails put the type column first in an index for a polymorphic association?</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Last week, I had a discussion with a coworker about how Rails indexes columns used in polymorphic associations. He thought that the order of columns in the index should be flipped - instead of indexing by type and ID, it should index by ID and type - as that way the most restrictive column is first, and therefore the index is more efficient. While I argued that the way that Rails indexes polymorphic associations is very pragmatic while also being efficient.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;em&gt;First off, what are we talking about?&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;We have an Access Log model in our app. It holds a log of who granted access to which device and when. But the thing granting access can be a Person, another Device or a Schedule.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;In Rails, an association that can point to many different types of models is called a &lt;a href="https://guides.rubyonrails.org/association_basics.html#polymorphic-associations"&gt;"polymorphic associations"&lt;/a&gt;.&lt;/div&gt;&lt;pre&gt;class AccessLog &amp;lt; ApplicationRecord
  belongs_to :device
  belongs_to :grantor, polymorphic: true
end

# Since grantor is polymorphic we can assign any type of model to it
log = AccessLog.create!(device: Device.all.sample, grantor: Person.all.sample)
log.grantor.class # =&amp;gt; Person

log.update!(grantor: Device.all.sample)
log.grantor.class # =&amp;gt; Device

log.update!(grantor: Schedule.all.sample)
log.grantor.class # =&amp;gt; Schedule

# Since the device association isn't polimorphic we can't assign anything
# but a device to it
log.update!(device: Person.all.sample) # =&amp;gt; ActiveRecord::AssociationTypeMismatch: Device expected, got Person&lt;/pre&gt;&lt;div&gt;A polymorphic and a regular association are implemented differently in the database.&amp;nbsp;&lt;br&gt;&lt;br&gt;A regular association is just a column that holds an ID that points to a record in the other model's table; usually it also has a foreign key constraint, which ensures that the assigned ID actually exists in the other table.&amp;nbsp;&lt;br&gt;&lt;br&gt;While a polymorphic association uses two columns, one to hold the type of model it's for and another to hold the ID of the record it's for.&lt;/div&gt;&lt;pre&gt;create_table "access_logs", force: :cascade do |t|
  # Regular association - holds the ID of the record from the devices table
  t.bigint "device_id", null: false
  
  # Polymorphic association
  t.string "grantor_type", null: false # holds the model name
  t.bigint "grantor_id", null: false # holds the ID of the record
  
  t.index ["device_id"], name: "index_access_logs_on_device_id"
  t.index ["grantor_type", "grantor_id"], name: "index_access_logs_on_entry"
end

# ensures that every ID in device_id coresponds to an id in the devices table
add_foreign_key "access_logs", "devices"
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;When you generate an association, Rails will automatically create an index for it. In the case of a polymorphic association, it will create a composite index - &lt;em&gt;that's an index that indexes two or more columns simultaneously&lt;/em&gt; - in which it will put the type column first and the ID column last.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;em&gt;Why does it matter which column is first in the index?&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This has to do with how databases build indices. By default, MySQL, MariaDB and PostgreSQL build &lt;a href="https://en.wikipedia.org/wiki/B-tree"&gt;b-tree&lt;/a&gt; indices.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;An index is similar to a regular book index, but instead of having chapter names and page numbers, it has values from the indexed column and internal record IDs.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MTc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--64b66ecb6c4f2a15c53a9c0e4a955f62f9d9fa3d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE3LCJwdXIiOiJibG9iX2lkIn19--ca222d263ae0695dcec67e543339459364b06e89/Pasted%20image%2020240319080622.png" filename="Pasted image 20240319080622.png" filesize="93969" width="936" height="945" previewable="true" presentation="gallery" caption="Comparison between a book and a naive database index"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Comparison between a book and a naive database index" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE3LCJwdXIiOiJibG9iX2lkIn19--ca222d263ae0695dcec67e543339459364b06e89/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240319080622.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Comparison between a book and a naive database index
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The problem with this kind of index is that it will grow as the number of distinct values in the indexed column grows.&amp;nbsp;&lt;br&gt;&lt;br&gt;E.g. if you create an index on a column that holds a person's email address, this kind of index would have one entry for each email, and the database would have to scan through each email until it finds the right one. To speed things up, we can do what dictionaries usually do and create a multi-level index.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;In a dictionary, you'd usually have an index that points you to another index. E.g. the main index would tell you that the index for all words starting with R is on page 6. Then the R sub-index would tell you that words starting with RU are on pages 342 to 361. Then you'd go to page 342 and search for the word &lt;em&gt;"Ruby"&lt;/em&gt; until you reached page 361.&lt;/div&gt;&lt;div&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MTg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--2ca21f998a91720a781979f4acb9026319d30e7a" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE4LCJwdXIiOiJibG9iX2lkIn19--9bd68888c93f3464cf3e583a949557e454b58300/Pasted%20image%2020240401104044.png" filename="Pasted image 20240401104044.png" filesize="56273" width="703" height="483" previewable="true" presentation="gallery" caption="A multi-level index from a dictionary"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A multi-level index from a dictionary" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE4LCJwdXIiOiJibG9iX2lkIn19--9bd68888c93f3464cf3e583a949557e454b58300/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240401104044.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A multi-level index from a dictionary
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;br&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MTk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--1d9a77e21ea17006c80be59121a36291a8914d3f" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE5LCJwdXIiOiJibG9iX2lkIn19--c10c2b3da3112338043f76c18200b62983148226/Pasted%20image%2020240401104111.png" filename="Pasted image 20240401104111.png" filesize="46072" width="663" height="396" previewable="true" presentation="gallery" caption="A naive multi-level index in a database"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A naive multi-level index in a database" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTE5LCJwdXIiOiJibG9iX2lkIn19--c10c2b3da3112338043f76c18200b62983148226/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240401104111.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A naive multi-level index in a database
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;B-trees are similar to multi-level indices in dictionaries, though they have extra rules and nuances that aren't important now.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;What's important is that a b-tree index can have a sub-index, which in term can have a sub-index, and so on. And the more items each sub-index has, the slower it is to find something.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;em&gt;These indices work for single values such as words and emails, but how would you make an index of two words, or an email and a number?&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;To create a composite index, most databases just concatenate the values together and build a regular b-tree index. This is a gross simplification of what actually happens, but in essence, that's what they do. Because of that, the order of the columns becomes important.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;If you tell the database to build an index over &lt;strong&gt;&lt;em&gt;grantor_type&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; first and then &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_id&lt;/em&gt;&lt;/strong&gt; it would take the two and concatenate them together like so &lt;em&gt;"Person|443"&lt;/em&gt;. But if you tell it to build an index over &lt;strong&gt;&lt;em&gt;grantor_id&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; first and then &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_type&lt;/em&gt;&lt;/strong&gt; it would concatenate them like so &lt;em&gt;"443|Person"&lt;/em&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The order of the columns in the index changes the structure of the index.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;When the ID column is first, the index points to fewer rows, so the database has to scan through fewer rows, and therefore it can find a match quicker. That's why it's a general recommendation to put the most specific column first in a composite index - it narrows down the search a lot.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MjE_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--6195d47519896dce95b5f3f28b9bc64b274422b3" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTIxLCJwdXIiOiJibG9iX2lkIn19--da88509e677e564522a929e4aa37deb6ec8187ce/Pasted%20image%2020240401105701.png" filename="Pasted image 20240401105701.png" filesize="69570" width="1044" height="590" previewable="true" presentation="gallery" caption="Naive composite database index with the ID column first and type column second"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Naive composite database index with the ID column first and type column second" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTIxLCJwdXIiOiJibG9iX2lkIn19--da88509e677e564522a929e4aa37deb6ec8187ce/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240401105701.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Naive composite database index with the ID column first and type column second
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;When the type column is first, there are a lot more rows to scan through in the end.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MjI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--2d88778aa6f0119474811df27527a702d90f07bd" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTIyLCJwdXIiOiJibG9iX2lkIn19--af8e16ee3b37c47947c09ff528a9cd94f758a826/Pasted%20image%2020240401104647.png" filename="Pasted image 20240401104647.png" filesize="90704" width="1321" height="716" previewable="true" presentation="gallery" caption="Naive composite database index with the type column first and ID column second"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Naive composite database index with the type column first and ID column second" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTIyLCJwdXIiOiJibG9iX2lkIn19--af8e16ee3b37c47947c09ff528a9cd94f758a826/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240401104647.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Naive composite database index with the type column first and ID column second
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;em&gt;Why would anyone create an index and put the type column first then?&lt;br&gt;&lt;/em&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;You don't have to follow a multi-level index all the way to the last index entry to find something. E.g. if you are searching for all words that start with R, you can go from the main index to find all sub-indices of words starting with R. Take the start page of the first index and the last page of the last index. Now you know that all words between these two pages start with R, and you didn't have to scan through anything.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;In a real app, you'd sometimes want to filter all records that are associated with another record of a certain type.&lt;/strong&gt; E.g. you might want to fetch all Access Logs that were granted by a Schedule, or all People associated with a Property through an Access Log.&lt;/div&gt;&lt;pre&gt;class Property &amp;lt; ApplicationRecord
  has_many :access_logs
  
  has_many :schedule_activations,
    -&amp;gt; { where(grantor_type: "Schedule") },
    class_name: "AccessLog"
    
  has_many :active_people,
    through: :access_logs,
    source: :grantor,
    source_type: "Person"
end

# Calling
Propert.find(42).schedule_activations.count
# Executes the following SQL statement
# ```sql
# SELECT COUNT(*)
# FROM access_logs
# WHERE property_id = 42 
#   AND grantor_type = 'Schedule'
# ```

# Calling
Propert.find(42).active_people.count
# Executes the following SQL statement
# ```sql
# SELECT COUNT(tenants.*)
# FROM tenants INNER JOIN access_logs
#   ON tenants.id = access_logs.grantor_id
# WHERE access_logs.property_id = 42
#   AND access_logs.grantor_type = 'Person'
&lt;br&gt;&lt;/pre&gt;&lt;div&gt;&lt;strong&gt;In that case, the composite index over &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_id&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; first and then &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_type&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; is basically useless.&lt;/strong&gt; The database would have to traverse the whole index to aggregate a range that it then has to scan through.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;&lt;strong&gt;While the index over &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_type&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; first and then &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;grantor_id&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; can quickly give you the range that you have to scan through by looking at the first and last entry of a sub-index.&lt;br&gt;&lt;/strong&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;We can test this out.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;I created a test MariaDB database, with an access_logs table that I've filled with a million rows. Then I created a composite index over &lt;em&gt;grantor_type&lt;/em&gt; and &lt;em&gt;grantor_id&lt;/em&gt; that I've named &lt;em&gt;test_index&lt;/em&gt;.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;When I ask MariaDB to explain how it would go about counting all rows where the &lt;em&gt;grantor_type&lt;/em&gt; is &lt;em&gt;Person&lt;/em&gt;, &lt;strong&gt;it says that it would use &lt;/strong&gt;&lt;strong&gt;&lt;em&gt;test_index&lt;/em&gt;&lt;/strong&gt;&lt;strong&gt; to generate the count&lt;/strong&gt;.&lt;/div&gt;&lt;pre&gt;MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);
Query OK, 0 rows affected (1.209 sec)
Records: 0  Duplicates: 0  Warnings: 0

MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = 'Person';                                                 
+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+
| id   | select_type | table         | type | possible_keys | key        | key_len | ref   | rows   | Extra                    |
+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+
|    1 | SIMPLE      | access_logs   | ref  | test_index    | test_index | 183     | const | 498290 | Using where; Using index |
+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+
1 row in set (0.002 sec)

MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = 'Person';
+----------+
| COUNT(1) |
+----------+
|   333400 |
+----------+
1 row in set (0.042 sec)&lt;/pre&gt;&lt;div&gt;If I ask the same question, but change the order of columns in the index, &lt;strong&gt;then MariaDB says that it can't use any index to generate the count but it still tries to use test_index&lt;/strong&gt;, and it takes about twice as long to count the rows.&lt;/div&gt;&lt;pre&gt;MariaDB [test]&amp;gt; DROP INDEX test_index ON access_logs;
Query OK, 0 rows affected (0.045 sec)
Records: 0  Duplicates: 0  Warnings: 0

MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_id, grantor_type);
Query OK, 0 rows affected (1.146 sec)
Records: 0  Duplicates: 0  Warnings: 0

MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = 'Person';
+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+
| id   | select_type | table         | type  | possible_keys | key        | key_len | ref  | rows   | Extra                    |
+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+
|    1 | SIMPLE      | access_logs   | index | NULL          | test_index | 192     | NULL | 996580 | Using where; Using index |
+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+
1 row in set (0.000 sec)

MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = 'Person';
+----------+
| COUNT(1) |
+----------+
|   333400 |
+----------+
1 row in set (0.095 sec)&lt;/pre&gt;&lt;div&gt;These results will vary from database to database, but the principle still stands.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;For example, &lt;a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html"&gt;PostgreSQL can sometimes utilize an index even when it isn't optimal&lt;/a&gt;. In the case of an index for a polymorphic association, changing the order of the columns has much less impact than in MariaDB. In both cases, PostgreSQL uses the index and produces a result in about the same time.&lt;/div&gt;&lt;pre&gt;postgres=# CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);
CREATE INDEX

postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM access_logs WHERE grantor_type = 'Person';                                                                      
QUERY PLAN                                                                       -----------------------------------------------------------------------------------------------------------------------------------------------------------------------                                                                            Finalize Aggregate  (cost=6457.79..6457.80 rows=1 width=8) (actual time=12.408..13.780 rows=1 loops=1)                                                ​-&amp;gt;  Gather  (cost=6457.57..6457.78 rows=2 width=8) (actual time=12.403..13.777 rows=3 loops=1)
    Workers Planned: 2
	Workers Launched: 2
	-&amp;gt;  Partial Aggregate  (cost=5457.57..5457.58 rows=1 width=8) (actual time=10.314..10.314 rows=1 loops=3)
	  -&amp;gt;  Parallel Index Only Scan using test_index on access_logs  (cost=0.42..5107.64 rows=139972 width=0) (actual time=0.027..6.566 rows=111133 loops=3)
	    Index Cond: (grantor_type = 'Person'::text)                                      Heap Fetches: 0
Planning Time: 0.145 ms
Execution Time: 13.801 ms
(10 rows)

postgres=# DROP INDEX test_index;
DROP INDEX

postgres=# CREATE INDEX test_index ON door_releases (grantor_id, grantor_type);
CREATE INDEX

postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM door_releases WHERE grantor_type = 'Person';
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=13778.29..13778.30 rows=1 width=8) (actual time=14.570..15.922 rows=1 loops=1)
   -&amp;gt;  Gather  (cost=13778.08..13778.29 rows=2 width=8) (actual time=14.566..15.919 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         -&amp;gt;  Partial Aggregate  (cost=12778.08..12778.09 rows=1 width=8) (actual time=12.210..12.210 rows=1 loops=3)
               -&amp;gt;  Parallel Index Only Scan using test_index on door_releases  (cost=0.42..12428.15 rows=139972 width=0) (actual time=0.037..8.808 rows=111133 loops=3)
                     Index Cond: (grantor_type = 'Person'::text)
                     Heap Fetches: 0
 Planning Time: 0.079 ms
 Execution Time: 15.944 ms
(10 rows)&lt;/pre&gt;&lt;div&gt;Simply put.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The type column is first in an index for a polymorphic association because then the same index can be used to speed up association look-ups (querying by both type and ID columns) and to filter by the type of the associated record (querying by just the type column).&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;These are the two most common use cases that you'll encounter in a Rails app. And also the use-cases for which Rails has built-in interfaces for (e.g. the &lt;em&gt;source_type&lt;/em&gt; option).&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This is more efficient in terms of speed (index creation and updating) and space (the size of the index on disk) than having two or more separate indices, which would be required in some databases if the columns were flipped.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last week, I had a discussion with a coworker about how Rails indexes columns used in polymorphic associations. He thought that the order of columns in the index should be flipped - instead of indexing by type and ID, it should index by ID and type - as that way the most restrictive column is...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/37</id>
    <published>2024-02-06T07:10:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/running-campfire-behind-traefik-LrGQM0GE0FVd"/>
    <title>Running Campfire behind traefik</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;I rent a bare metal server from &lt;a href="https://www.scaleway.com/en/"&gt;Scaleway&lt;/a&gt; where I host all my apps. For me, that's much more economical than running multiple small VPS boxes. 50€ a month buys me 2TB of disk space, 64GB of RAM and an 4 core / 8 thread CPU on which I can host two or three dozen apps. Compare that to AWS or DigitalOcean where for the same money I get about one quarter of those resources.&lt;br&gt;&lt;/p&gt;&lt;p&gt;All my apps are deployed as Docker containers, most of them using &lt;a href="https://kamal-deploy.org/"&gt;Kamal&lt;/a&gt;, in front of which I put &lt;a href="https://traefik.io/traefik/"&gt;traefik&lt;/a&gt; as a reverse proxy to direct traffic and provide HTTPS via &lt;a href="https://letsencrypt.org/"&gt;Let's Encrypt&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Last weekend I wanted to setup a Campfire instance for Ruby Zagreb - my local Ruby user-group - on that machine.&lt;br&gt;&lt;/p&gt;&lt;p&gt;So I ran the official installer and got an error.&lt;/p&gt;&lt;pre data-language="bash"&gt;#!/usr/bin/bash&lt;br&gt;sudo once setup 1111-1111-1111-1111 &lt;br&gt;&lt;br&gt; ██████╗ ███╗   ██╗ ██████╗███████╗&lt;br&gt;██╔═══██╗████╗  ██║██╔════╝██╔════╝&lt;br&gt;██║   ██║██╔██╗ ██║██║     █████╗  &lt;br&gt;██║   ██║██║╚██╗██║██║     ██╔══╝  &lt;br&gt;╚██████╔╝██║ ╚████║╚██████╗███████╗&lt;br&gt; ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝╚══════╝&lt;br&gt;                            &lt;br&gt;Failed to start the software&lt;br&gt;                            &lt;br&gt;                                                                                                                                                                                                                                              &lt;br&gt;The software couldn't be started. Please try again. &lt;/pre&gt;&lt;p&gt;I expected the installer to fail since it can't know my setup, and it didn't ask me anything besides the domain of the instance.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Sadly, the email you get with your purchase doesn't explain what the installer does, and I can't check what it's doing since the installer is a binary blob. So I had to snoop around for a bit.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The first thing I found was the installer's error log which said that the container couldn't bind to port 443.&lt;/p&gt;&lt;pre data-language="bash"&gt;cat .once-error.log&lt;br&gt;2024/01/27 19:33:11 Error response from daemon: driver failed programming external connectivity on endpoint campfire (e32f1a334f8ac25b9f3df0c283adb3eb3e8a554074f0827d17f8b566d03940a5): Bind for 0.0.0.0:443 failed: port is already allocated&lt;/pre&gt;&lt;p&gt;Since traefik is bound to port 80 and 443 this makes perfect sense.&lt;br&gt;&lt;/p&gt;&lt;p&gt;The error looks like it came from Docker so I checked my images, and sure enough there was a new &lt;i&gt;&lt;em&gt;"registry.once.com/campfire:latest"&lt;/em&gt;&lt;/i&gt; image and a stopped container which I promptly inspected.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Within the container runs a custom proxy called &lt;a href="https://github.com/rails/rails/issues/50479"&gt;thruster&lt;/a&gt; that terminates SSL and handles the &lt;a href="https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/#enabling-sendfile"&gt;sendfile protocol&lt;/a&gt;. That's what's bound to port 443 and 80 of the container.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Inside the container is also a Redis instance, which implies that the installer sets at least &lt;i&gt;&lt;em&gt;"sysctl vm.overcommit_memory=1"&lt;/em&gt;&lt;/i&gt; else Redis wouldn't start.&lt;br&gt;&lt;/p&gt;&lt;p&gt;I also saw that there were ENV variables injected into the container so I looked where those came from and found a config file in &lt;i&gt;&lt;em&gt;"/root/.config/once/config.json"&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;pre data-language="bash"&gt;sudo cat /root/.config/once/config.json | jq&lt;br&gt;{&lt;br&gt;  "token": "1111-1111-1111-1111",&lt;br&gt;  "product": "campfire",&lt;br&gt;  "product_name": "Campfire",&lt;br&gt;  "email_address": "phony.email@example.com",&lt;br&gt;  "ssl_domain": "chat.rubyzg.org",&lt;br&gt;  "validation_token": "PHONY_VALIDATION_KEY",&lt;br&gt;  "secret_key_base": "PHONY_SECRET_KEY_BASE",&lt;br&gt;  "vapid_private_key": "PHONY_PRIVATE_KEY",&lt;br&gt;  "vapid_public_key": "PHONY_PUBLIC_KEY",&lt;br&gt;  "storage_location": "/var/once/campfire",&lt;br&gt;  "cron_hour": 2,&lt;br&gt;  "once_binary_etag": ""&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Seems pretty straight forward. This is roughly what the installer does&lt;/p&gt;&lt;pre data-language="bash"&gt;sudo sysctl vm.overcommit_memory=1&lt;br&gt;&lt;br&gt;docker run \&lt;br&gt;	-d \&lt;br&gt;	--name campfire \&lt;br&gt;	--restart unless-stopped \&lt;br&gt;	--env-file campfire/.env \&lt;br&gt;	-p 443&lt;br&gt;	-p 80&lt;br&gt;	-v campfire/storage:/rails/storage \&lt;br&gt;	registry.once.com/campfire:latest \&lt;br&gt;	bin/boot;;&lt;br&gt;&lt;/pre&gt;&lt;p&gt;Where &lt;i&gt;&lt;em&gt;"campfire/.env"&lt;/em&gt;&lt;/i&gt; contains the values from &lt;i&gt;&lt;em&gt;"/root/.config/once/config.json"&lt;/em&gt;&lt;/i&gt; plus a few extras.&lt;/p&gt;&lt;pre data-language="bash"&gt;# campfire/.env &lt;br&gt;SECRET_KEY_BASE=PHONY_SECRET_KEY_BASE&lt;br&gt;VAPID_PRIVATE_KEY=PHONY_PRIVATE_KEY&lt;br&gt;VAPID_PUBLIC_KEY=PHONY_PUBLIC_KEY&lt;br&gt;SSL_DOMAIN=chat.rubyzg.org&lt;br&gt;DISABLE_SSL=YES &lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;To run Campfire without the installer, behind traefik, all I had to do is&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;pre data-language="bash"&gt;sudo sysctl vm.overcommit_memory=1&lt;br&gt;&lt;br&gt;docker run \&lt;br&gt;	-d \&lt;br&gt;	--name campfire \&lt;br&gt;	--network apps \&lt;br&gt;	--restart unless-stopped \&lt;br&gt;	--env-file campfire/.env \&lt;br&gt;	--label-file campfire/labels \&lt;br&gt;	-v campfire/Procfile:/rails/Procfile \&lt;br&gt;	-v campfire/production.rb:/rails/config/environments/production.rb \&lt;br&gt;	-v campfire/storage:/rails/storage \&lt;br&gt;	registry.once.com/campfire:latest \&lt;br&gt;	bin/boot;;&lt;/pre&gt;&lt;p&gt;Put this into &lt;i&gt;&lt;em&gt;"campfire/labels"&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;pre data-language="plain"&gt;# Enable traefik for the container &lt;br&gt;traefik.enable=true &lt;br&gt;&lt;br&gt;# Convert HTTP traffic to HTTPS &lt;br&gt;traefik.http.routers.campfire.entrypoints=websecure&lt;br&gt;traefik.http.routers.campfire.tls.certresolver=letsencrypt&lt;br&gt;&lt;br&gt;# Register the container to a domain - CHANGE THIS TO YOUR DOMAIN &lt;br&gt;traefik.http.routers.campfire.rule=Host(`chat.rubyzg.org`) &lt;br&gt;&lt;br&gt;# Return a service not available screen if Campfire isn't running &lt;br&gt;traefik.http.middlewares.campfire.retry.attempts=10 &lt;br&gt;traefik.http.middlewares.campfire.retry.initialinterval=1s &lt;br&gt;traefik.http.services.campfire.loadbalancer.healthcheck.path=/up &lt;br&gt;traefik.http.services.campfire.loadbalancer.server.port=80 &lt;br&gt;traefik.http.services.campfire.loadbalancer.server.scheme=http &lt;/pre&gt;&lt;p&gt;Finally, I had to disable thruster and bind Puma to port 80 by updating the "web" entry in the Procfile (you get the source files in the purchase email)&lt;/p&gt;&lt;pre data-language="plain"&gt;# campfire/Procfile &lt;br&gt;web: bin/rails server -b 0.0.0.0 -p 80 &lt;br&gt;# ... &lt;/pre&gt;&lt;p&gt;This will boot the server, it will allow you to setup an admin account, create rooms, add a logo, &lt;b&gt;&lt;strong&gt;but you won't be able to post messages because Action Cable can't connect to the server&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Traefik has &lt;a href="https://github.com/traefik/traefik/issues/6076"&gt;a bug where it doesn't forward x-forwarded-for headers for WebSocket handshakes&lt;/a&gt;.&lt;br&gt;&lt;br&gt;To fix that I had to update &lt;i&gt;&lt;em&gt;"production.rb"&lt;/em&gt;&lt;/i&gt; with the exact URL that Action Cable would get requests from&lt;/p&gt;&lt;pre data-language="ruby"&gt;# production.rb&lt;br&gt;# ...&lt;br&gt;config.action_cable.url = "wss://#{ENV.fetch("SSL_DOMAIN")}/cable"&lt;br&gt;config.action_cable.allowed_request_origins = ["https://#{ENV.fetch("SSL_DOMAIN")}"]&lt;br&gt;# ...&lt;/pre&gt;&lt;p&gt;That's it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;It's isn't as straight forward as I'd like it to be, and it would be nice if Kamal and Campfire could play along nicely out of the box. I'd bet there is a large overlap between those communities, and both are 37signals projects.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">I rent a bare metal server from Scaleway where I host all my apps. For me, that's much more economical than running multiple small VPS boxes. 50€ a month buys me 2TB of disk space, 64GB of RAM and an 4 core / 8 thread CPU on which I can host two or three dozen apps. Compare that to AWS or...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/36</id>
    <published>2024-02-05T13:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/deconstructing-action-cable-DC7F33OsjGmK"/>
    <title>Deconstructing Action Cable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDM_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--aa1cc49ba66fd6279e8b54d764475a2f846291fc" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTAzLCJwdXIiOiJibG9iX2lkIn19--b922449e26a0d70fc8cd220e3404325e9c43fe75/00011-2829697632.png" filename="00011-2829697632.png" filesize="614577" width="904" height="512" previewable="true" presentation="gallery" caption="Deconstructing Action Cable"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Deconstructing Action Cable" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTAzLCJwdXIiOiJibG9iX2lkIn19--b922449e26a0d70fc8cd220e3404325e9c43fe75/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/00011-2829697632.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Deconstructing Action Cable
  &lt;/figcaption&gt;
&lt;/figure&gt;I knew what Action Cable was about and roughly how it worked since it was released. But lately I've developed a much deeper understanding of it because I had to extend it and explain its internals to others.&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;The following is how I explained Action Cable to myself. I took a top-down approach, starting at what I could observe Action Cable doing in my browser and following the code until I found the implementation, which I then deconstructed to understand why it does what it does.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This was a along journey down a deep rabbit hole, but it was worth it. At the end I now understand how and why Action Cable does what it does, and appreciate how simple and elegant it actually is.&lt;/p&gt;&lt;h2&gt;From HTTP to WebSocket&lt;/h2&gt;&lt;p&gt;Action Cable is a framework that allows your server to push messages to the browser, or any other client.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Usually your server can only respond to requests that a client, like your browser, makes to it. But Action Cable opens a WebSocket between the client and the server through which the server can push messages to the client at any time.&lt;/p&gt;&lt;p&gt;&lt;br&gt;The client can also push messages to the server through that WebSocket. But that's in my opinion less interesting as we could do that without Action Cable.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;How does the client connect to Action Cable's WebSocket?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;When anyone or anything wants to connect to your Rails app via Action Cable they first have to make a HTTP request to "/cable" and after a bit of back and forth out pops a WebSocket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;But how does a HTTP request become a Websocket?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Back in 1997 HTTP version 1.1 was released. It's very similar to HTTP 1.0 with a few additions, the one that's important for WebSockets is &lt;a href="https://datatracker.ietf.org/doc/html/rfc2616#section-10.1.2"&gt;the addition of status 101 aka. Switching protocols and the &lt;i&gt;&lt;em&gt;"Upgrade"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;header&lt;/a&gt;. The intent of status 101 was to enable browsers to gracefully transition from HTTP 1.x to an eventual HTTP 2 or some other protocol that would be incompatible with HTTP 1.&lt;br&gt;&lt;/p&gt;&lt;p&gt;And that's exactly what a browser does when it connects to a WebSocket. It first makes a request with the &lt;i&gt;&lt;em&gt;"Upgrade"&lt;/em&gt;&lt;/i&gt; header set to "websocket" which tells the server "hey, I want to switch to using WebSockets for this request". If the server supports WebSockets it responds with status 101 indicating that the upgrade was accepted and that, from now on, it will communicate using the WebSocket protocol on that request's connection.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDQ_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--5a68beb829d7cb9c21f7e6cf2922b1476bda48d0" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA0LCJwdXIiOiJibG9iX2lkIn19--f6ae760fe0f0ebfe84c7e2b5b2f45ee5400f67f3/Pasted%20image%2020240124151038.png" filename="Pasted image 20240124151038.png" filesize="196098" width="771" height="756" previewable="true" presentation="gallery" caption="A WebSocket upgrade request"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A WebSocket upgrade request" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA0LCJwdXIiOiJibG9iX2lkIn19--f6ae760fe0f0ebfe84c7e2b5b2f45ee5400f67f3/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240124151038.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A WebSocket upgrade request
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;i&gt;&lt;em&gt;But why do we need the HTTP request in the first place? Can't we just connect directly via the WebSocket protocol since we already know that the server supports it?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;The genius of WebSockets lies in the initial HTTP request. Because of that initial HTTP request we have access to all the browser's cookies that are sent over, so we can use them to figure out who wants to open a WebSocket, if they are allowed to do that, and we can remember who the socket is for.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;So Action Cable converts HTTP requests to WebSockets?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Yes! It's a WebSocket server, but it also does so much more.&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;h2&gt;Connections&lt;/h2&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;So someone's connected to our WebSocket, but how do we know who we are talking to and how can we send messages to them?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;In Action Cable, each open WebSocket has a corresponding Connection object. The connection object is responsible for keeping track of who the WebSocket is for and figuring out if the client is allowed to open a WebSocket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;When a client makes the initial HTTP request to &lt;i&gt;&lt;em&gt;"/cable"&lt;/em&gt;&lt;/i&gt;, Action Cable will take the request's headers, cookies, URL and create a Connection object from it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;If it exists, the Connection object will be an instance of &lt;i&gt;&lt;em&gt;"ApplicationCable::Connection"&lt;/em&gt;&lt;/i&gt;. If that class doesn't exist it will be an instance of &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb"&gt;&lt;i&gt;&lt;em&gt;"ActionCable::Connection::Base"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;. You can change the class of the connection by setting &lt;i&gt;&lt;em&gt;"config.action_cable.connection_class = -&amp;gt;{ WhateverClassIWant }"&lt;/em&gt;&lt;/i&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;When a connection is established the &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L186"&gt;&lt;i&gt;&lt;em&gt;"#connect"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; method on our &lt;i&gt;&lt;em&gt;"ApplicationCable::Connection"&lt;/em&gt;&lt;/i&gt; object will get called. In that method you'd usually want to figure out who opened the connection, and reject it if they aren't allowed to open it.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ApplicationCable&lt;br&gt;  class Connection &amp;lt; ActionCable::Connection::Base&lt;br&gt;    attr_accessor :current_person&lt;br&gt;    &lt;br&gt;    def connect&lt;br&gt;	  Rails.logger.debug "Someone wants to connect via WebSocket!"&lt;br&gt;	  set_current_person&lt;br&gt;	  &lt;br&gt;	  unless current_person&lt;br&gt;	    Rails.logger.debug "I don't know who this is. Closing the WebSocket."&lt;br&gt;	    reject_unauthorized_connection&lt;br&gt;	  end&lt;br&gt;	  &lt;br&gt;	  Rails.logger.debug "It's, #{current_person.name}"&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    private&lt;br&gt;&lt;br&gt;    def set_current_person&lt;br&gt;      person = &lt;br&gt;	    # We can access the param of the HTTP request&lt;br&gt;	    Person.find_by(token: request.params[:token]) ||&lt;br&gt;	    # We can access the session of the HTTP request&lt;br&gt;        Person.find_by(token: request.session[:person_id])&lt;br&gt;        &lt;br&gt;      unless person&lt;br&gt;        # We can access the cookies of the HTTP request&lt;br&gt;        session = Session.find_by(id: cookies.encrypted[:session_id])&lt;br&gt;        person = session&amp;amp;.person&lt;br&gt;      end&lt;br&gt;	  &lt;br&gt;	  self.current_person = person&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;There is also a &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L204"&gt;&lt;i&gt;&lt;em&gt;"#disconnect"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; method that gets called when a WebSocket is closed. It's useful if you want to set something up in &lt;i&gt;&lt;em&gt;"#connect"&lt;/em&gt;&lt;/i&gt; and then tear it down when the WebSocket closes. E.g. if you'd want to count the number of open connections that a person has, you could do something like this.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ApplicationCable&lt;br&gt;  class Connection &amp;lt; ActionCable::Connection::Base&lt;br&gt;    attr_accessor :current_person&lt;br&gt;    &lt;br&gt;    def connect&lt;br&gt;	  set_current_person&lt;br&gt;	  reject_unauthorized_connection unless current_person&lt;br&gt;&lt;br&gt;	  current_person.increment!(:connection_count)&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    def disconnect&lt;br&gt;      current_person&amp;amp;.decrement!(:connection_count)&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    private&lt;br&gt;&lt;br&gt;    # ...&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Now you know who the WebSocket connection is for, but Rails doesn't. To tell Rails which attr_accessor(s) we'll use for identification we have to change our &lt;i&gt;&lt;em&gt;"attr_accessor"&lt;/em&gt;&lt;/i&gt; to &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/identification.rb#L20"&gt;&lt;i&gt;&lt;em&gt;"identified_by"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;, which is a class method that Action Cable provides.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ApplicationCable&lt;br&gt;  class Connection &amp;lt; ActionCable::Connection::Base&lt;br&gt;    #attr_accessor :current_person # CHANGE THIS&lt;br&gt;    identified_by :current_person # TO THIS&lt;br&gt;    &lt;br&gt;    def connect&lt;br&gt;	  set_current_person&lt;br&gt;	  reject_unauthorized_connection unless current_person&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    private&lt;br&gt;&lt;br&gt;    # ...&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Ok, now we've got a connection and we know who it's for, how do we send a message?&lt;/em&gt;&lt;/i&gt; Do we have to send some kind of JSON or XML or whatnot?&lt;/p&gt;&lt;h2&gt;The Wild WebSocket West&lt;/h2&gt;&lt;p&gt;We are used to HTTP in our Rails apps. With it we can transfer text, images, audio, video and more from our server to the browser. But WebSockets are the wild west of the Web.&lt;br&gt;&lt;/p&gt;&lt;p&gt;As the "socket" part implies, a WebSocket is similar to a Unix, TCP or UDP socket in that you can transfer whatever you like over it and in any fashion you'd like. It's up to you to implement a protocol between your server and the browser on top of your WebSocket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Luckily we don't have to implement anything as &lt;b&gt;&lt;strong&gt;Action Cable is not only a WebSocket server but also a protocol.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;When you send a message to a client - or vice versa - the the object you are sending will first be turned into a JSON-compatible object like a String, a Number, a Hash, or an Array. Then it will be wrapped inside a Hash under a key called "message" together with some additional things I'll explain in a bit. Then that Hash will be dumped as JSON and sent as an UTF-8 encoded String, byte for byte through the WebSocket. On the other end the message is assembled into a String again and parsed into an object.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDU_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--aa3e6b1f4a5ef77604f38cde33c898adbd9b154c" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA1LCJwdXIiOiJibG9iX2lkIn19--87d43f71fc7bff0b010a4a630da56a2ebb7a32b3/Pasted%20image%2020240124152110.png" filename="Pasted image 20240124152110.png" filesize="41574" width="775" height="232" previewable="true" presentation="gallery" caption="Raw message sent via ActionCable to the browser"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Raw message sent via ActionCable to the browser" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA1LCJwdXIiOiJibG9iX2lkIn19--87d43f71fc7bff0b010a4a630da56a2ebb7a32b3/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240124152110.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Raw message sent via ActionCable to the browser
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;i&gt;&lt;em&gt;Why do we need the wrapper Hash?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;The wrapper Hash allows us to have different types of messages. The protocol has three types of messages that a client can send to the server:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;subscribe&lt;/li&gt;&lt;li&gt;unsubscribe&lt;/li&gt;&lt;li&gt;message&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;And six kinds of messages that the server can send to the client:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;welcome&lt;/li&gt;&lt;li&gt;disconnect&lt;/li&gt;&lt;li&gt;ping&lt;/li&gt;&lt;li&gt;confirm_subscription&lt;/li&gt;&lt;li&gt;reject_subscription&lt;/li&gt;&lt;li&gt;message&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L211"&gt;&lt;b&gt;&lt;strong&gt;The "welcome" message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; is the simplest of them all. It's just a Hash with a key "type" that holds the value "welcome". All messages sent from the server to a client have a "type" field. This is always the first message that a server sends to any client that connects to it. It's used to indicate to the client that the WebSocket is functional.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "type": "welcome"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L135"&gt;&lt;b&gt;&lt;strong&gt;The "ping" message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; is used to keep a heartbeat. It's sent to all connected clients every 3 seconds so that the connection doesn't become stale and therefore terminated by some load balance in the middle. And it enables clients to detect when they have disconnected from the server. If the client doesn't receive a heartbeat message within 6 seconds - that's 2 heartbeats - it will try to reconnect. In addition to the "type" field, a ping message will also have a "message" field which holds the Unix timestamp of the moment the message was sent. This timestamp isn't used in the official client implementation.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "type": "ping",&lt;br&gt;  "message": 1705848059&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/base.rb#L110"&gt;&lt;b&gt;&lt;strong&gt;The "disconnect" message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; instructs a client to disconnect from the server. In addition to the "type" field it has a "reconnect" field which holds a Boolean. If the value of this field is true, the client should immediately try to reconnect after it disconnects. This is useful in case an error occurs and the connection has to be restarted. And in addition to the "reconnect" field there is a "reason" field which holds a String that gives you a brief explanation why your client is being disconnected.&lt;/p&gt;&lt;p&gt;There are four possible disconnect reasons:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;i&gt;&lt;em&gt;unauthorized&lt;/em&gt;&lt;/i&gt; - sent when "reject_unauthorized_connection" is called&lt;/li&gt;&lt;li&gt;&lt;i&gt;&lt;em&gt;invalid_request&lt;/em&gt;&lt;/i&gt; - sent when the initial HTTP request was malformed&lt;/li&gt;&lt;li&gt;&lt;i&gt;&lt;em&gt;server_restart&lt;/em&gt;&lt;/i&gt; - sent when the server is about to restart&lt;/li&gt;&lt;li&gt;&lt;i&gt;&lt;em&gt;remote&lt;/em&gt;&lt;/i&gt; - send when the client is kicked for whatever reason&lt;/li&gt;&lt;/ul&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "type": "disconnect",&lt;br&gt;  "reconnect": true,&lt;br&gt;  "reason": "server_restart"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;To explain the &lt;i&gt;&lt;em&gt;"message"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"subscribe"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"confirm_subscription"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"reject_subscription"&lt;/em&gt;&lt;/i&gt; &amp;amp; &lt;i&gt;&lt;em&gt;"unsubscribe"&lt;/em&gt;&lt;/i&gt; messages I first have to explain Channels.&lt;/p&gt;&lt;h2&gt;Channels&lt;/h2&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;What are Channels?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Channels are Action Cable's controllers.&lt;br&gt;&lt;/p&gt;&lt;p&gt;In a regular HTTP request you'd specify which method you want to call on which Controller by setting the request's path and method. E.g. if you'd want to call the &lt;i&gt;&lt;em&gt;"create"&lt;/em&gt;&lt;/i&gt; method of the &lt;i&gt;&lt;em&gt;"ArticlesController"&lt;/em&gt;&lt;/i&gt; you'd make a POST request to "/articles" and pass any parameters you'd like the new Article object to have.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ArticlesController&lt;br&gt;  # POST /articles&lt;br&gt;  def create&lt;br&gt;    if Article.create(params.require(:article).permit(:title, :content))&lt;br&gt;      redirect_to action: :show, status: :see_other&lt;br&gt;    else&lt;br&gt;      render :new, status: :unprocesssable_entity&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;In Action Cable you specify which method you want to call on which Channel. To do that, you first have to subscribe to to a Channel and then you have to send a message to it.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def post_message(data)&lt;br&gt;	  Chat::Message.create(&lt;br&gt;	    content: data[:content], &lt;br&gt;	    poster: current_person&lt;br&gt;	  )&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;(Note that we get access to &lt;i&gt;&lt;em&gt;"current_person"&lt;/em&gt;&lt;/i&gt; because we used &lt;i&gt;&lt;em&gt;"identified_by"&lt;/em&gt;&lt;/i&gt; in our Connection object)&lt;br&gt;&lt;/p&gt;&lt;p&gt;Now if you want to invoke &lt;i&gt;&lt;em&gt;"ChatChannel#post_message"&lt;/em&gt;&lt;/i&gt; you first have to subscribe to the &lt;i&gt;&lt;em&gt;"ChatChannel"&lt;/em&gt;&lt;/i&gt; with a &lt;i&gt;&lt;em&gt;"subscribe"&lt;/em&gt;&lt;/i&gt; message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L88"&gt;&lt;b&gt;&lt;strong&gt;The "subscribe" message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; has a &lt;i&gt;&lt;em&gt;"command"&lt;/em&gt;&lt;/i&gt; field instead of a &lt;i&gt;&lt;em&gt;"type"&lt;/em&gt;&lt;/i&gt; field. All messages sent from the client to the server have a &lt;i&gt;&lt;em&gt;"command"&lt;/em&gt;&lt;/i&gt; field. In addition to that it also has an &lt;i&gt;&lt;em&gt;"identifier"&lt;/em&gt;&lt;/i&gt; field which holds a JSON encoded Hash. The identifier Hash has at least a &lt;i&gt;&lt;em&gt;"channel"&lt;/em&gt;&lt;/i&gt; field which is the class name of the channel. But it can also have other, user defined, values which will become available in the Channel object through the &lt;i&gt;&lt;em&gt;"params"&lt;/em&gt;&lt;/i&gt; method.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "command": "subscribe",&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;These params can be used however you'd like. E.g.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def post_message(data)&lt;br&gt;	  current_person&lt;br&gt;	    .chat_rooms&lt;br&gt;	    .find_by(name: params[:room_name]) # PARAMS FROM THE SUBSCRIBE MESSAGE&lt;br&gt;	    &amp;amp;.messages&lt;br&gt;	    &amp;amp;.create(content: data[:content])&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;When someone subscribes to a Channel the &lt;i&gt;&lt;em&gt;"subscribed"&lt;/em&gt;&lt;/i&gt; method on the channel will be called. In it you can do various things, one of which is to authorize the subscription. Let's say that you want to reject subscriptions for chat rooms that a person isn't a member of, you could do something like this&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def subscribed&lt;br&gt;    @chat_room = current_person&lt;br&gt;	  .chat_rooms&lt;br&gt;	  .find_by(name: params[:room_name])&lt;br&gt;	  &lt;br&gt;	reject unless @chat_room&lt;br&gt;  end&lt;br&gt;  &lt;br&gt;  def post_message(data)&lt;br&gt;	@chat_room.messages.create(content: data[:content])&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;If the &lt;i&gt;&lt;em&gt;"subscribed"&lt;/em&gt;&lt;/i&gt; method doesn't call &lt;i&gt;&lt;em&gt;"reject"&lt;/em&gt;&lt;/i&gt; the server will respond with a &lt;i&gt;&lt;em&gt;"confirm_subscription"&lt;/em&gt;&lt;/i&gt; message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L301"&gt;&lt;b&gt;&lt;strong&gt;The &lt;/strong&gt;&lt;/b&gt;&lt;i&gt;&lt;b&gt;&lt;strong&gt;"confirm_subscription"&lt;/strong&gt;&lt;/b&gt;&lt;/i&gt;&lt;b&gt;&lt;strong&gt;&amp;nbsp;message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; has a &lt;i&gt;&lt;em&gt;"type"&lt;/em&gt;&lt;/i&gt;, and an &lt;i&gt;&lt;em&gt;"identifier"&lt;/em&gt;&lt;/i&gt; field. The identifier holds the same value that was sent in the original &lt;i&gt;&lt;em&gt;"subscribe"&lt;/em&gt;&lt;/i&gt; message.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "type": "confirm_subscription",&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;If &lt;i&gt;&lt;em&gt;"reject"&lt;/em&gt;&lt;/i&gt; was called then the server will respond with a &lt;i&gt;&lt;em&gt;"reject_subscription"&lt;/em&gt;&lt;/i&gt; message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/channel/base.rb#L316"&gt;&lt;b&gt;&lt;strong&gt;The "reject_subscription" message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; has the same format as the "confirm_subscription" message.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "type": "reject_subscription",&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Now that the client is subscribed they can invoke an action on the Channel using the regular message type.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The "message" type messages&lt;/strong&gt;&lt;/b&gt; has two variants - one for messages sent from the server to the client, and another for messages sent from the client to the server.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L83"&gt;&lt;b&gt;&lt;strong&gt;If the client is sending the message&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;, then the message will contain a &lt;i&gt;&lt;em&gt;"command"&lt;/em&gt;&lt;/i&gt; field with the value "message", an &lt;i&gt;&lt;em&gt;"identifier"&lt;/em&gt;&lt;/i&gt; and a &lt;i&gt;&lt;em&gt;"data"&lt;/em&gt;&lt;/i&gt; field. Again, the identifier holds the same value that was sent in the original &lt;i&gt;&lt;em&gt;"subscribe"&lt;/em&gt;&lt;/i&gt; message. While data can be anything depending on what the client has sent.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "command": "message",&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }",&lt;br&gt;  "data": "Hello, Zagreb!"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L167C11-L167C25"&gt;When the channel receives a message&lt;/a&gt;, it will check if it implements a &lt;i&gt;&lt;em&gt;"receive"&lt;/em&gt;&lt;/i&gt; method and if it does it will invoke it and pass the message's data to it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Though, if the client sent over a Hash, then the Channel will check if the data Hash contains the key "action" with a String value. If it does, and the Channel implements a method with the same name, then it will be invoked to process the message's data.&lt;/p&gt;&lt;pre data-language="json"&gt;{&lt;br&gt;  "command": "message",&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }",&lt;br&gt;  "data": {&lt;br&gt;    "action": "post_message",&lt;br&gt;    "content": "Hello, Zagreb!"&lt;br&gt;  }&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;I'll explain the server-to-client message format in a moment.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Now we know how to send messages from the client to Action Cable. But how do we send messages from Action Cable to the client?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;This is in my opinion the more interesting part. To send something we first have to create a stream to which we can publish messages to.&lt;br&gt;&lt;/p&gt;&lt;p&gt;A stream is a PubSub channel. I &lt;i&gt;&lt;em&gt;guess&lt;/em&gt;&lt;/i&gt; it's called a stream in Action Cable because, semantically, this PubSub channel acts like a stream of messages for the client.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;What is a PubSub channel?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern"&gt;PubSub (short for Publish-subscribe)&lt;/a&gt; is a common programming pattern for sending messages between objects. An object can subscribe to messages from, or publish messages to, a channel (sometimes called a topic). When one object publishes a message to a channel, all objects subscribed to that channel receive it. The message can be anything - a String, a Hash, a Number, or another Object.&lt;/p&gt;&lt;p&gt;&lt;br&gt;In Action Cable, our Channel object is the subscriber and our application (controllers, models, jobs, ...) is the publisher.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDY_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--6cf2b66f2101c96bc30b45ad50dd62dbc7bfa635" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA2LCJwdXIiOiJibG9iX2lkIn19--be1246b9ba0d759098a571da395185b64312dc89/Pasted%20image%2020240128081709.png" filename="Pasted image 20240128081709.png" filesize="53140" width="979" height="274" previewable="true" presentation="gallery" caption="Illustration of how PubSub works"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Illustration of how PubSub works" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA2LCJwdXIiOiJibG9iX2lkIn19--be1246b9ba0d759098a571da395185b64312dc89/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240128081709.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Illustration of how PubSub works
  &lt;/figcaption&gt;
&lt;/figure&gt;To create a stream we can use either &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/streams.rb#L78"&gt;&lt;i&gt;&lt;em&gt;"stream_from"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; which creates a PubSub channel using a String identifier that we pass to it &lt;i&gt;&lt;em&gt;(e.g. "chat:1337")&lt;/em&gt;&lt;/i&gt;. Or &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/streams.rb#L103"&gt;&lt;i&gt;&lt;em&gt;“stream_for”&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; which accepts an object &lt;i&gt;&lt;em&gt;(e.g. "Chat.find(1337)")&lt;/em&gt;&lt;/i&gt;. &lt;i&gt;&lt;em&gt;"stream_for"&lt;/em&gt;&lt;/i&gt; internally generates a String identifier for the given object and calls &lt;i&gt;&lt;em&gt;"stream_from"&lt;/em&gt;&lt;/i&gt; with it.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def subscribed&lt;br&gt;    @chat_room = current_person&lt;br&gt;	  .chat_rooms&lt;br&gt;	  .find_by(name: params[:room_name])&lt;br&gt;	  &lt;br&gt;	reject unless @chat_room&lt;br&gt;&lt;br&gt;    # creates a PubSub channel to which we can publish messages to from anywhere&lt;br&gt;    stream_for @chat_room&lt;br&gt;    # the above is basicaly the same as&lt;br&gt;    # stream_from "chat_room:#{@chat_room.id}"&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Now that we have a stream we can publish messages to it from anywhere using &lt;i&gt;&lt;em&gt;"broadcast_to"&lt;/em&gt;&lt;/i&gt; which accepts two arguments - the object we are broadcasting to, and the messages to broadcast.&lt;/p&gt;&lt;pre data-language="ruby"&gt;ChatChannel.broacast_to(&lt;br&gt;  ChatRoom.find(params[:id]), # The same record that we gave to stream_for&lt;br&gt;  "Hello, Zagreb!" # The message I want to send&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;If you used &lt;i&gt;&lt;em&gt;"stream_from"&lt;/em&gt;&lt;/i&gt; to create your stream, then you have to use &lt;i&gt;&lt;em&gt;"ActionCable.server.broadcast"&lt;/em&gt;&lt;/i&gt; instead of &lt;i&gt;&lt;em&gt;"broadcast_to"&lt;/em&gt;&lt;/i&gt;. It also expects two arguments - the stream we are broadcasting to, and the message we are broadcasting.&lt;/p&gt;&lt;pre data-language="ruby"&gt;ActionCable.server.broadcast("chat_room:123", "Hello, Zagreb!")&lt;/pre&gt;&lt;p&gt;You can also send a message directly from the Channel object using the &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/channel/base.rb#L214"&gt;&lt;i&gt;&lt;em&gt;"transmit"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;method&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def subscribed&lt;br&gt;    @chat_room = current_person&lt;br&gt;	  .chat_rooms&lt;br&gt;	  .find_by(name: params[:room_name])&lt;br&gt;	  &lt;br&gt;	reject unless @chat_room&lt;br&gt;&lt;br&gt;    transmit "Welcome back, #{current_person.first_name}!"&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Ok, but what happens when we broadcast or transmit a message?&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;p&gt;Well, &lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/base.rb#L223"&gt;it's wrapped in a hash containing two keys&lt;/a&gt; - &lt;i&gt;&lt;em&gt;"identifier"&lt;/em&gt;&lt;/i&gt; and &lt;i&gt;&lt;em&gt;"message"&lt;/em&gt;&lt;/i&gt;. The identifier holds the same value that was sent in the original &lt;i&gt;&lt;em&gt;"subscribe"&lt;/em&gt;&lt;/i&gt; message. While the message holds whatever you are sending to the client.&lt;/p&gt;&lt;pre data-language="javascript"&gt;{&lt;br&gt;  "identifier": "{ &lt;br&gt;    \"channel\": \"ChatChannel\", &lt;br&gt;    \"room_name\": \"Ruby Zagreb\" &lt;br&gt;  }",&lt;br&gt;  "message": "Hello, Zagreb!"&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Then that Hash is turned into JSON and sent via the WebSocket.&lt;/p&gt;&lt;p&gt;When the client gets that message, it can figure out for which of its subscriptions it's for based on the &lt;i&gt;&lt;em&gt;"identifier"&lt;/em&gt;&lt;/i&gt;, and then it can process the &lt;i&gt;&lt;em&gt;"message"&lt;/em&gt;&lt;/i&gt; however it likes.&lt;/p&gt;&lt;h2&gt;In the browser&lt;/h2&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Now we have sent a message from the server, but how can we respond to it in a browser?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://www.npmjs.com/package/@rails/actioncable"&gt;Action Cable ships with an official JavaScript client&lt;/a&gt; that enables you to connect to a server via Action Cable, subscribe to any channel, and receive messages. Just like its server counterpart, it hides the protocol from you and allows you to focus only on the messages.&lt;br&gt;&lt;/p&gt;&lt;p&gt;To connect to a server you have to create a &lt;i&gt;&lt;em&gt;consumer&lt;/em&gt;&lt;/i&gt;, which is a wrapper around a WebSocket connection to our server.&lt;br&gt;&lt;/p&gt;&lt;p&gt;You can create a consumer that will connect to "/cable" without any extra params just by calling &lt;i&gt;&lt;em&gt;"createConsumer"&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;/pre&gt;&lt;p&gt;If you want to pass extra params to the server's Connection object, e.g. for authentication, you'll have to pass the exact URL with params and all to &lt;i&gt;&lt;em&gt;"createConsumer"&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;// Fetches the user's auth token from a meta tag in the DOM&lt;br&gt;const token = document.querySelector("meta[name=auth_token]")?.content&lt;br&gt;&lt;br&gt;// Takes the current URL,&lt;br&gt;// changes it's path to "/ws",&lt;br&gt;// and adds "?token=#{token}" as the query params.&lt;br&gt;// The result looks something like "https://example.com/ws?token=123"&lt;br&gt;const webSocketURL = new URL(window.location.href)&lt;br&gt;webSocketURL.pathname = "/ws" // usually it's "/cable"&lt;br&gt;webSocketURL.search = `?token=${token}`&lt;br&gt;&lt;br&gt;const consumer = createConsumer(webSocketURL.toString())&lt;/pre&gt;&lt;p&gt;Now that you have a consumer, you can subscribe with it to any channel you'd like. To subscribe, you have to create a subscription with an identifier.&lt;br&gt;&lt;/p&gt;&lt;p&gt;If you remember from before, an identifier has to have a &lt;i&gt;&lt;em&gt;"channel"&lt;/em&gt;&lt;/i&gt; field, but can also have any additional fields you want and you'll have access to these fields as &lt;i&gt;&lt;em&gt;"params"&lt;/em&gt;&lt;/i&gt; in your Channel object.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;// Subscribe to the ChatChannel &lt;br&gt;// and pass { room_name: "Ruby Zagreb" } as params&lt;br&gt;consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" }&lt;br&gt;)&lt;br&gt;&lt;/pre&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;How do I process an incoming message with this?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Well, &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L154"&gt;when your subscription receives a message it will try to call a &lt;i&gt;&lt;em&gt;"received"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;method of the subscription object&lt;/a&gt; to process the message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;You can create a received method on the subscription yourself, like so&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;// Subscribe to the ChatChannel &lt;br&gt;// and pass { room_name: "Ruby Zagreb" } as params&lt;br&gt;const subscription = consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" }&lt;br&gt;)&lt;br&gt;&lt;br&gt;subscription.received = function(message) {&lt;br&gt;  document&lt;br&gt;    .querySelector("#messages")&lt;br&gt;    ?.insertAdjacentHTML("beforeend", `&amp;lt;div&amp;gt;${message}&amp;lt;/div&amp;gt;`)&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;Though this approach has a problem - if you get a message immediately as you subscribe, but before you create your received method, you'll miss that message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Action Cable allows you to extend the Subscription object as you create it to avoid this problem. You can pass a second argument while creating a subscription. That argument has to be an object with which the newly created Subscription object will be extended. Extending an object in JavaScript is similar to including a mixin in a class in Ruby.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;// Subscribe to the ChatChannel &lt;br&gt;// and pass { room_name: "Ruby Zagreb" } as params&lt;br&gt;const subscription = consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" },&lt;br&gt;  {&lt;br&gt;    received(message) {&lt;br&gt;	  this.appendMessage(message)&lt;br&gt;    }&lt;br&gt;    &lt;br&gt;    appendMessage(message) {&lt;br&gt;	  this.messageContainer()?.insertAdjacentHTML(&lt;br&gt;		"beforeend", &lt;br&gt;		`&amp;lt;div&amp;gt;${message}&amp;lt;/div&amp;gt;`&lt;br&gt;	  )&lt;br&gt;    }&lt;br&gt;    &lt;br&gt;    messageContainer() {&lt;br&gt;	  document.querySelector("#messages")&lt;br&gt;    }&lt;br&gt;  }&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;But there are other events that you can process in your Subscription object besides receiving a message.&lt;br&gt;&lt;/p&gt;&lt;p&gt;You can process &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L34"&gt;initialization events&lt;/a&gt;, &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L147"&gt;connect&lt;/a&gt; and &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/connection.js#L172"&gt;disconnect&lt;/a&gt; events, as well as subscription &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscriptions.js#L50"&gt;rejections&lt;/a&gt;. Each event requires you to define a method to process it. Initialization requires an &lt;i&gt;&lt;em&gt;"initialized"&lt;/em&gt;&lt;/i&gt; method, rejection a &lt;i&gt;&lt;em&gt;"rejected"&lt;/em&gt;&lt;/i&gt; method, connect and disconnect a &lt;i&gt;&lt;em&gt;"connected"&lt;/em&gt;&lt;/i&gt; and &lt;i&gt;&lt;em&gt;"disconnected"&lt;/em&gt;&lt;/i&gt; method.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;const subscription = consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" },&lt;br&gt;  {&lt;br&gt;    // Called right after the Subscription object is created&lt;br&gt;    initialized() {&lt;br&gt;      console.log(`A subscription to ${this.identifier} was created`)&lt;br&gt;    }&lt;br&gt;&lt;br&gt;    // Called if the subscription was rejected by the server&lt;br&gt;	rejected() {&lt;br&gt;	  console.log(`The server rejected the subscription to ${this.identifier}`)&lt;br&gt;	}&lt;br&gt;&lt;br&gt;    // Called when the subscription gets confirmed by the server&lt;br&gt;    connected(data) {&lt;br&gt;	  // The data object has a single property `reconnected`&lt;br&gt;	  // which indicates if this was a resubscribe after a disconnect&lt;br&gt;      console.log(`The server confirmed the subscription to ${this.identifier}! Reconnected: ${data.reconnected}`)&lt;br&gt;    }&lt;br&gt;&lt;br&gt;    // Called when the WebSocket closes&lt;br&gt;	disconnected(data) {&lt;br&gt;	  // The data object has a single propert `willAttemptReconnect`&lt;br&gt;	  // which indicates if a reconnect attempt will be made or not&lt;br&gt;	  console.log(`WebSocket to ${this.consumer.url} closed! Will attempt reconnect: ${data.willAttemptReconnect}`)&lt;br&gt;	}&lt;br&gt;&lt;br&gt;    // Called when a message is sent from the server&lt;br&gt;    received(message) {&lt;br&gt;	  console.log(`Received message: ${message}`)&lt;br&gt;    }&lt;br&gt;  }&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;There are also two actions that you can perform from your subscription - send messages to the server, and unsubscribe.&lt;br&gt;&lt;/p&gt;&lt;p&gt;To unsubscribe just call &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L86"&gt;&lt;i&gt;&lt;em&gt;"unsubscribe"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;const subscription = consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" },&lt;br&gt;  {&lt;br&gt;    received(message) {&lt;br&gt;      console.log(`Received message: ${message}`)&lt;br&gt;      &lt;br&gt;      if (message.toLowerCase() === "avada kedavra") this.unsubscribe()&lt;br&gt;    }&lt;br&gt;  }&lt;br&gt;)&lt;br&gt;&lt;/pre&gt;&lt;p&gt;And to send a message call the &lt;a href="https://github.com/rails/rails/blob/c7551d08fc572655105da38394d8672b8259c22f/actioncable/app/javascript/action_cable/subscription.js#L82"&gt;&lt;i&gt;&lt;em&gt;"send"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;method&lt;/a&gt; with the message you want to send.&lt;/p&gt;&lt;pre data-language="javascript"&gt;import { createConsumer } from "@rails/actioncable"&lt;br&gt;&lt;br&gt;const consumer = createConsumer()&lt;br&gt;&lt;br&gt;const subscription = consumer.subscriptions.create(&lt;br&gt;  { channel: "ChatChannel", room_name: "Ruby Zagreb" }&lt;br&gt;)&lt;br&gt;&lt;br&gt;subscription.send({ action: "post_message", content: "Hello, Zagreb!" })&lt;br&gt;&lt;/pre&gt;&lt;h2&gt;Thousands of connections, one server&lt;/h2&gt;&lt;p&gt;Now you know how to connect to Action Cable and send messages back and forth. But if you have ever tuned a Rails application you probably know that there is a &lt;a href="https://en.wikipedia.org/wiki/Thread_pool"&gt;thread pool&lt;/a&gt; that determines how many simulations requests your server can process.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Does that mean that there is a maximum number of connections that Action Cable can handle?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Well, no. As long as your server has memory available it will be able to accept and serve more Action Cable connections. Granted, it will process them slower and slower, but it will work.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;How does that work?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/puma/puma"&gt;Puma&lt;/a&gt;, currently the most popular Ruby/Rack application server, is a multi-threaded server. This means that when you make a request to it, your request will be assigned to one thread that will process it and generate a response.&lt;/p&gt;&lt;p&gt;But, you don't want to have too many threads. Each thread running on your server gets a very short slice of time to run its code on the processor. The more threads you have, the more any one thread has to wait to get to run its code on the processor.&lt;br&gt;&lt;/p&gt;&lt;p&gt;So the smarter thing to do is to have a preset number of threads - a thread pool - that can process requests. If a request comes in, and all threads in the pool are busy, the request goes into a queue. It will wait there until a thread becomes available. That's why you want to take as little time as possible to generate a response for a request. The more a request takes to process, the higher the chance that some requests will end up in the queue which means that your app feels slow.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;But Action Cable turns requests into WebSockets, which can stay open for days, how don't we run out of threads in the pool?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;That's the interesting part. &lt;b&gt;&lt;strong&gt;Action Cable processes only the initial HTTP request in Puma's thread pool.&lt;/strong&gt;&lt;/b&gt; After you get a 101 response from the server the request is "hijacked" and put into an event loop. &lt;b&gt;&lt;strong&gt;So Puma's thread pool determines the maximum number of WebSocket connections that can be opened at once.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;i&gt;&lt;em&gt;Hijack? Event loop? What?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Rails doesn't integrate directly with an application server &lt;i&gt;&lt;em&gt;(like Puma)&lt;/em&gt;&lt;/i&gt;. Instead it implements Rack's protocol through which it gets requests from the application server and returns responses to it. This enables applications and frameworks like Rails to work with any application server like Puma, &lt;a href="https://github.com/ruby/webrick"&gt;WebBrick&lt;/a&gt; or &lt;a href="https://github.com/socketry/falcon"&gt;Falcon&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Rack is great for request-response interactions like HTTP, but a WebSocket isn't request-response like. It's a bi-directional stream of data.&lt;/p&gt;&lt;p&gt;To support WebSockets and similar stream-like protocols, Rack offers a way to read and write bytes directly to and from a connection initiated by a request - it's called hijacking the socket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;When you hijack a Rack request you get it's underlying socket. You can read and write bytes to the sockets freely. But this means that it's your responsibility to implement whatever protocol you want over that socket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/connection/stream.rb#L96"&gt;Action Cable hijacks the socket&lt;/a&gt; and passes it to &lt;a href="https://github.com/faye/websocket-driver-ruby/tree/main"&gt;Faye's WebSocket Driver&lt;/a&gt; library which implements the WebSocket protocol and it puts that socket into an &lt;a href="https://en.wikipedia.org/wiki/Event_loop"&gt;event loop&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;An event loop is a pattern for responding to events. You start an infinite loop in which you wait for some event, when it occurs you process it and the loop starts again.&lt;br&gt;&lt;/p&gt;&lt;p&gt;In Action Cable's case it's waiting for bytes to read from the socket it hijacked. When they are available, it reads them and passes them on to the WebSocket driver.&lt;br&gt;&lt;/p&gt;&lt;p&gt;That's easy enough for one socket. But to wait for any of an infinite number of sockets to have some bytes ready to be read would require an infinite number of threads - one for each socket.&lt;br&gt;&lt;/p&gt;&lt;p&gt;To avoid spawning a thread for each WebSocket, Action Cable uses &lt;a href="https://github.com/socketry/nio4r"&gt;nio4r&lt;/a&gt; &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/connection/stream_event_loop.rb#L95"&gt;which notifies it when a socket has some bytes ready to be read&lt;/a&gt;, without spawning any threads. It does so using different functions of the server's operating system's kernel - such as &lt;a href="https://ruby-doc.org/3.2.2/IO.html#method-c-select"&gt;select&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/Epoll"&gt;epoll&lt;/a&gt; and &lt;a href="https://en.wikipedia.org/wiki/Kqueue"&gt;kqueue&lt;/a&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;Once it's notified that &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L95"&gt;a socket is ready&lt;/a&gt;, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L109"&gt;it reads its bytes&lt;/a&gt;, parses them into a message, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream_event_loop.rb#L116"&gt;and then passes the message to the Connection object&lt;/a&gt;, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/base.rb#L87"&gt;which then puts in in a thread pool to be processed&lt;/a&gt; - &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/server/worker.rb#L10"&gt;called the worker pool&lt;/a&gt; - to process that message.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--c9adff6975a06248a19728e17cdd4102dfe121b8" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA4LCJwdXIiOiJibG9iX2lkIn19--6a3d1d71c954fe9efc0e6d79bf9c4ee9aa432aba/Pasted%20image%2020240204081845.png" filename="Pasted image 20240204081845.png" filesize="51350" width="1107" height="256" previewable="true" presentation="gallery" caption="Illustration of all the pools that a request goes through"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Illustration of all the pools that a request goes through" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA4LCJwdXIiOiJibG9iX2lkIn19--6a3d1d71c954fe9efc0e6d79bf9c4ee9aa432aba/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240204081845.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Illustration of all the pools that a request goes through
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;i&gt;&lt;em&gt;Another thread pool?&lt;/em&gt;&lt;/i&gt;&lt;br&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Yes. &lt;b&gt;&lt;strong&gt;This worker pool allows Action Cable to process multiple incoming messages simultaneously.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;You can tweak the size of this worker pool by setting &lt;i&gt;&lt;em&gt;"config.action_cable.worker_pool_size"&lt;/em&gt;&lt;/i&gt; to the number of threads you'd like to have in that pool.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The more threads in this pool the higher the number of incoming messages that you can process concurrently&lt;/strong&gt;&lt;/b&gt;. More threads also means that your latency will go up (as one threads will have to wait for longer to get access to the processor), your database connection count will go up (as more threads can access the database simultaneously), and your memory consumption will go up as more messages and objects are in memory at the same time.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But there is one more thread pool in Action Cable - the event loop thread pool.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This one is used to dispatch events like &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/server/connections.rb#L29"&gt;sending heart beat messages&lt;/a&gt;, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/internal_channel.rb#L22"&gt;subscribing to&lt;/a&gt; and &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/internal_channel.rb#L29"&gt;unsubscribing from&lt;/a&gt; PubSub channels created by stream_from, &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream.rb#L104"&gt;attaching&lt;/a&gt; and &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/connection/stream.rb#L110"&gt;detaching&lt;/a&gt; hijacked sockets, and &lt;a href="https://github.com/rails/rails/blob/3d132b855541c35236e93b3a676a3ee80d02a38a/actioncable/lib/action_cable/channel/periodic_timers.rb#L67"&gt;triggering periodic timers&lt;/a&gt; (to which I'll get to in just a bit).&lt;br&gt;&lt;/p&gt;&lt;p&gt;This thread pool has a fixed size as it's intended to be an event dispatch of sorts where you just schedule a task which the pool only trigger, the execution of the task is mostly done in the worker pool.&lt;/p&gt;&lt;h2&gt;Things I wish I knew right away&lt;/h2&gt;&lt;p&gt;There are a few things that I learned about Action Cable that I wish I knew earlier.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You can trigger actions and send messages periodically&lt;/strong&gt;&lt;/b&gt;, like every few seconds. This is extremely useful if you have some state that you want to synchronize periodically, or some house keeping you want to do.&lt;br&gt;&lt;/p&gt;&lt;p&gt;In your Channel object you can call a &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/channel/periodic_timers.rb#L31"&gt;&lt;i&gt;&lt;em&gt;"periodically"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;class method&lt;/a&gt;, give it a method name or a block, and an interval. It will then run that method/block for you with whatever interval you specified.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class StockTickerChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  # this will send a message to the client every 2 seconds &lt;br&gt;  # as long as they are subscribed&lt;br&gt;  periodically every: 2.seconds do&lt;br&gt;    transmit value: @stock.value, timestamp: Time.now.to_i&lt;br&gt;  end&lt;br&gt;  &lt;br&gt;  def subscribed&lt;br&gt;    @stock = Stock.find_by(symbol: params[:symbol])&lt;br&gt;	  &lt;br&gt;	reject unless @stock&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Some error trackers won't catch errors from your Channel objects unless you explicitly send them.&lt;/strong&gt;&lt;/b&gt; You can do that in your Connection using &lt;i&gt;&lt;em&gt;"rescue_from"&lt;/em&gt;&lt;/i&gt;, just like you would in a Controller.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ApplicationCable&lt;br&gt;  class Connection &amp;lt; ActionCable::Connection::Base&lt;br&gt;    rescue_from Exception do |error|&lt;br&gt;      MyErrorTracker.capture_exception(error)&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    # ...&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;There is a callback on the Connection object for when an action gets invoked on your Channel object.&lt;/strong&gt;&lt;/b&gt; You can register such a callback using &lt;i&gt;&lt;em&gt;"before_command"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"after_command"&lt;/em&gt;&lt;/i&gt; and "around_command", which is extremely useful if you use &lt;a href="https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html"&gt;Current attributes&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ApplicationCable&lt;br&gt;  class Connection &amp;lt; ActionCable::Connection::Base&lt;br&gt;    around_command do&lt;br&gt;	  Current.set(person: current_person) { yield }&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    # ...&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;There are callbacks on the Channel object for when someone subscribes or unsubscribes.&lt;/strong&gt;&lt;/b&gt; There is "&lt;i&gt;&lt;em&gt;before_subscribe&lt;/em&gt;&lt;/i&gt;", &lt;i&gt;&lt;em&gt;"after_subscribe"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"around_subscribe"&lt;/em&gt;&lt;/i&gt;, and "&lt;i&gt;&lt;em&gt;before_unsubscribe&lt;/em&gt;&lt;/i&gt;", &lt;i&gt;&lt;em&gt;"after_unsubscribe"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"around_unsubscribe"&lt;/em&gt;&lt;/i&gt;. These are useful for implementing common behavior through inheritance or mixins without having to call "super" form the &lt;i&gt;&lt;em&gt;"subscribed"&lt;/em&gt;&lt;/i&gt; or &lt;i&gt;&lt;em&gt;"unsubscribed"&lt;/em&gt;&lt;/i&gt; method.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module AppearanceTrackable&lt;br&gt;  extend ActiveSupport::Concern&lt;br&gt;&lt;br&gt;  included do&lt;br&gt;	after_subscribe unless: :subscription_rejected? do&lt;br&gt;	  Current.person.came_online!&lt;br&gt;	end&lt;br&gt;	&lt;br&gt;	after_unsubscribe do&lt;br&gt;	  Current.person.went_offline!&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;class ChatChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  include AppearanceTrackable&lt;br&gt;  &lt;br&gt;  def subscribed&lt;br&gt;    @chat_room = current_person&lt;br&gt;	  .chat_rooms&lt;br&gt;	  .find_by(name: params[:room_name])&lt;br&gt;	  &lt;br&gt;	reject unless @chat_room&lt;br&gt;&lt;br&gt;    stream_for @chat_room&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You probably want to bump Puma's worker timeout if you are debugging your Connection object.&lt;/strong&gt;&lt;/b&gt; Puma will kill any worker that doesn't generate a response within 60 seconds.&lt;br&gt;&lt;/p&gt;&lt;p&gt;This can be annoying if you are trying to debug something with &lt;i&gt;&lt;em&gt;"debugger"&lt;/em&gt;&lt;/i&gt;, &lt;i&gt;&lt;em&gt;"binding.irb"&lt;/em&gt;&lt;/i&gt; or &lt;i&gt;&lt;em&gt;"binding.pry"&lt;/em&gt;&lt;/i&gt;.&lt;br&gt;&lt;/p&gt;&lt;p&gt;But you can raise that timeout using the &lt;i&gt;&lt;em&gt;"worker_timeout"&lt;/em&gt;&lt;/i&gt; method in &lt;i&gt;&lt;em&gt;"puma.rb"&lt;/em&gt;&lt;/i&gt;.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# config/puma.rb&lt;br&gt;require File.expand_path("../config/environment", File.dirname(__FILE__))&lt;br&gt;&lt;br&gt;# ...&lt;br&gt;&lt;br&gt;# Kill a worker thread if it didn't generate a responsw in 8 hours&lt;br&gt;worker_timeout 8 * 3600 if ENV.fetch("RAILS_ENV", "development") == "development"&lt;br&gt;&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You have to tweak both Puma's thread and Action Cable's worker counts.&lt;/strong&gt;&lt;/b&gt; Puma's thread count controls how many new WebSocket connections can be created simultaneously. Action Cable's worker pool size determines how many WebSocket messages you can process simultaneously. Both pools will create database connections!&lt;/p&gt;&lt;pre data-language="ruby"&gt;# config/puma.rb&lt;br&gt;max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }&lt;br&gt;min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }&lt;br&gt;threads min_threads_count, max_threads_count&lt;br&gt;&lt;br&gt;# config/application.rb&lt;br&gt;config.action_cable.worker_pool_size = ENV.fetch("RAILS_MAX_THREADS") { 5 }&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;In development, sometimes the server can behave a bit wonky.&lt;/strong&gt;&lt;/b&gt; I'm not a 100% sure what's going on but it seems like code reloading can cause rogue threads with stale code. If that happens just run "touch tmp/restart.txt" to quickly restart the server and reset everything.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;When a client looses it's Internet connection it will take Action Cable up to 20 min to notice that, close the connection, and trigger callbacks.&lt;/strong&gt;&lt;/b&gt; I wrote about that in my &lt;a href="https://stanko.io/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz"&gt;previous article&lt;/a&gt;. This can cause you headaches if you want to allow only a certain number of connections per client.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You can use the official JS client outside of the browser, like in Node or Bun.&lt;/strong&gt;&lt;/b&gt; The official client doesn't use &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket"&gt;the browser's WebSocket object&lt;/a&gt; directly. It exports an &lt;i&gt;&lt;em&gt;"adapters"&lt;/em&gt;&lt;/i&gt; object which has a property called &lt;i&gt;&lt;em&gt;"WebSocket"&lt;/em&gt;&lt;/i&gt; which holds the object that will be used to establish WebSocket connections. So you can drop-in your own WebSocket object and use it.&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You can remotely disconnect any client.&lt;/strong&gt;&lt;/b&gt; There is a &lt;i&gt;&lt;em&gt;"ActionCable::RemoteConnections"&lt;/em&gt;&lt;/i&gt; class that acts like an Active Record relation. You can query it using its &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/remote_connections.rb#L36"&gt;&lt;i&gt;&lt;em&gt;"where"&lt;/em&gt;&lt;/i&gt;&amp;nbsp;method&lt;/a&gt; to get a connection. The where method searches for a connection by its &lt;i&gt;&lt;em&gt;"identified_by"&lt;/em&gt;&lt;/i&gt; fields. If it finds such a connection it gives you a proxy for that connection on which you can only call &lt;a href="https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/remote_connections.rb#L56"&gt;&lt;i&gt;&lt;em&gt;"disconnect"&lt;/em&gt;&lt;/i&gt;&lt;/a&gt; which then disconnects the client.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class Person &amp;lt; Applicationrecord&lt;br&gt;  def ban!&lt;br&gt;    update!(banned: true)&lt;br&gt;    &lt;br&gt;    ActionCable::RemoteConnections&lt;br&gt;      .where(current_person: self)&lt;br&gt;      &amp;amp;.disconnect(reconnect: false)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;There are multiple PubSub adapters available besides Redis.&lt;/strong&gt;&lt;/b&gt; You can use Postgres instead of Redis as a backend (&lt;a href="https://www.postgresql.org/docs/current/sql-notify.html"&gt;it has some limitations&lt;/a&gt;). And you can provide your own if you need.&lt;/p&gt;&lt;pre data-language="plain"&gt;# config/cable.yml&lt;br&gt;production:&lt;br&gt;  adapter: postgres&lt;/pre&gt;&lt;h2&gt;Final thought&lt;/h2&gt;&lt;p&gt;Action Cable is an amazing piece of software. It can seem complex, but that’s because it does a lot. When you look at each piece individually the complexity goes away.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi81MDk_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--cb39a5efd14a34a59a6ac8a168fc0e1e5d6445bc" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA5LCJwdXIiOiJibG9iX2lkIn19--c28b2eb369c4519bff9d60ec5630c91618771ac4/Pasted%20image%2020240205082517.png" filename="Pasted image 20240205082517.png" filesize="63541" width="537" height="955" previewable="true" presentation="gallery" caption="Block overview of everything discussed"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Block overview of everything discussed" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTA5LCJwdXIiOiJibG9iX2lkIn19--c28b2eb369c4519bff9d60ec5630c91618771ac4/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Pasted%20image%2020240205082517.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Block overview of everything discussed
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;p&gt;&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">I knew what Action Cable was about and roughly how it worked since it was released. But lately I've developed a much deeper understanding of it because I had to extend it and explain its internals to others.

The following is how I explained Action Cable to myself. I took a top-down approach,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/14</id>
    <published>2024-01-24T12:00:00Z</published>
    <updated>2024-03-22T14:20:17Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/deconstructing-action-cable-uM1SflOFpRUk"/>
    <title>Deconstructing Action Cable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;A deep dive that explores and explains how Action Cable works starting from the initial HTTP request all the way to sending bytes over the WebSocket.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2024-01-24/Deconstructing_Action_Cable.pdf"&gt;Slides&lt;/a&gt; &lt;a href="https://stanko.io/deconstructing-action-cable-DC7F33OsjGmK"&gt;Article&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">A deep dive that explores and explains how Action Cable works starting from the initial HTTP request all the way to sending bytes over the WebSocket.

Slides Article</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/35</id>
    <published>2023-11-14T05:17:00Z</published>
    <updated>2026-03-09T08:42:16Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz"/>
    <title>Tracking online presence with ActionCable</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;This month I worked on tracking when a device connected and disconnected to a WebSocket backed with &lt;a href="https://guides.rubyonrails.org/action_cable_overview.html"&gt;ActionCable&lt;/a&gt;. At first, this seemed like a simple problem to solve, but it turned out to be much more complicated.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/tree/b86c5b02b4e411c5f43cd006629a8549dbbabd65"&gt;I started with a simple solution&lt;/a&gt;, which was to mark a Device as being online in the #subsribed method, and marking it as offline in the #unsubscribed method. These methods are called when a client subscribes or unsubscribes to a channel. &lt;a href="https://guides.rubyonrails.org/action_cable_overview.html#example-1-user-appearances"&gt;This is also the solution from the ActionCable guides&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class EventsChannel &amp;lt; ApplicationCable::Channel&lt;br&gt;  def subscribed&lt;br&gt;    Current.device.came_online&lt;br&gt;    stream_from Current.device&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def unsubscribed&lt;br&gt;    Current.device.went_offline&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;#came_online and #went_offline internally just update an online boolean column to either true or false, and also create a log record that the device came online or went offline.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class Device &amp;lt; ApplicationRecord&lt;br&gt;  has_many :online_status_changes, dependent: :destroy&lt;br&gt;&lt;br&gt;  scope :online, -&amp;gt; { where(online: true) }&lt;br&gt;  scope :offline, -&amp;gt; { where(online: false) }&lt;br&gt;  &lt;br&gt;  def came_online&lt;br&gt;    update!(online: true)&lt;br&gt;    online_status_changes.create!(status: :online)&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def went_offline&lt;br&gt;	update!(online: false)&lt;br&gt;	online_status_changes.create!(status: :offline) &lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;And that works pretty well. If I connect as a device I can see a green orb appear next to to the device's name, and if I disconnect the orb turns red.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi80OTI_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--0765f58931d0578a87473313894bdc29e80035b1" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDkyLCJwdXIiOiJibG9iX2lkIn19--9e238d7f25351c8d1a9c1fe01f674baa24040c3f/presence_app_demo.webm" filename="presence_app_demo.webm" filesize="347915" width="1920.0" height="1012.0058565153734" previewable="true" presentation="gallery" caption="Demonstration of the device going online and offline within a simple presence tracking app"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDkyLCJwdXIiOiJibG9iX2lkIn19--9e238d7f25351c8d1a9c1fe01f674baa24040c3f/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/presence_app_demo.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDkyLCJwdXIiOiJibG9iX2lkIn19--9e238d7f25351c8d1a9c1fe01f674baa24040c3f/presence_app_demo.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demonstration of the device going online and offline within a simple presence tracking app
  &lt;/figcaption&gt;
&lt;/figure&gt;I soon discovered a problem with that solution. If I open the page on my phone, connect as a device, and then turn airplane mode on, the app will still think that the device is online even through the phone isn't connected to WiFi anymore.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi80OTc_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--f214a5056a9583150d9be6ddad38aa7e5789e526" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk3LCJwdXIiOiJibG9iX2lkIn19--cf03897002eae451e81c03b024eccd239e3458a2/presence_app_offline_demo_av1.webm" filename="presence_app_offline_demo_av1.webm" filesize="3013833" width="1918.0" height="1080.0" previewable="true" presentation="gallery" caption="Demonstration of the device staying online when the device is unplugged from the Internet"&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk3LCJwdXIiOiJibG9iX2lkIn19--cf03897002eae451e81c03b024eccd239e3458a2/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/presence_app_offline_demo_av1.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk3LCJwdXIiOiJibG9iX2lkIn19--cf03897002eae451e81c03b024eccd239e3458a2/presence_app_offline_demo_av1.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demonstration of the device staying online when the device is unplugged from the Internet
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;What is going on?&lt;br&gt;&lt;br&gt;Well, most operating systems keep a connection "alive" and buffer messages for it if the other end suddenly disconnects (without closing the TCP connection). This is so that small hiccups in the network don't cause you problems, errors or warning messages. This connection state is known as &lt;a href="https://en.wikipedia.org/wiki/TCP_half-open"&gt;half-open&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Depending on the OS, a connection can be considered held half-open in this way anywhere from a few minutes to nearly half an hour, or until the buffer overflows which can be dozens to hundreds of megabytes of messages.&lt;p&gt;&lt;/p&gt;&lt;p&gt;Since I want to know when a device disconnects as soon as possible this buffer and the delay it introduces is a problem.&lt;br&gt;&lt;br&gt;This behavior surprised me a bit since I know that ActionCable keeps a heartbeat which is supposed to detect sudden disconnects and prevent half-open connections from lingering.&lt;br&gt;&lt;br&gt;Why didn't the heartbeat save me this trouble? I had to dive deep into ActionCable to figure out.&lt;br&gt;&lt;br&gt;A heartbeat is a simple message, sent back and forth between the client and server every few seconds. It generates traffic, so the connection doesn't become stale, and if one side doesn't receive the heartbeat in time it knows that it has lost connection to the other side. The heartbeat messages are commonly called "ping" and "pong". The WebSocket protocol itself has a special ping &amp;amp; pong message, but most WebSocket libraries implement their own application-level ping &amp;amp; pong for various reasons I won't get into.&lt;br&gt;&lt;br&gt;The ping message can be sent from the server to the client - this is known as a server-initiated ping - or from the client to the server - which is known as a client-initiated ping. Usually, whoever gets the ping responds back with a pong message. If one side doesn't receive a ping or pong back within a given time frame they assume that the other side has disconnected.&lt;br&gt;&lt;br&gt;Turns out, in ActionCable, the heartbeat is server-initiated and one-directional. This means that the server periodically sends a ping to all clients, but it doesn't expect a pong back.&lt;br&gt;&lt;br&gt;That's a bit curious, but it makes sense. The point is to help the client discover that it has lost connection so that it can reconnect. The downside of such a heartbeat is that the server can't know that the client has disconnected abruptly, so it can't close the connection until either the OS buffer overflows or times out.&lt;br&gt;&lt;br&gt;In my case, just adding a pong response to the client, and the ability for the server to close any connection that didn't receive a pong within two heartbeats would allow me to detect a disconnect within 9 sec which is a great improvement over the 30 min from before.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/tree/7d239de992654ec944784a02c55a3dd69823aa75"&gt;So I monkey patched that in&lt;/a&gt;, and it worked pretty well.&lt;action-text-attachment sgid="eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2Jsb2cvQWN0aXZlU3RvcmFnZTo6QmxvYi80OTg_ZXhwaXJlc19pbiIsInB1ciI6ImF0dGFjaGFibGUifX0=--15855d18c99820f8ddb89dea38323fee64632ef2" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk4LCJwdXIiOiJibG9iX2lkIn19--367f1b5629ffeb12090bbf2289bad97c8088ebbd/presence_app_pong_monkeypatch.webm" filename="presence_app_pong_monkeypatch.webm" filesize="2728875" width="1920.0" height="1080.0" previewable="true" presentation="gallery" caption="Demonstration of the device going offline when unplugged when PONG messages are added"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk4LCJwdXIiOiJibG9iX2lkIn19--367f1b5629ffeb12090bbf2289bad97c8088ebbd/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/presence_app_pong_monkeypatch.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDk4LCJwdXIiOiJibG9iX2lkIn19--367f1b5629ffeb12090bbf2289bad97c8088ebbd/presence_app_pong_monkeypatch.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Demonstration of the device going offline when unplugged when PONG messages are added
  &lt;/figcaption&gt;
&lt;/figure&gt;In my opinion this is a generally useful feature so I went to open up a PR to ActionCable and checked for any preexisting PRs or issues related to ping and pong messages. There were two closed issues (&lt;a href="https://github.com/rails/rails/issues/24908"&gt;#24908&lt;/a&gt; &amp;amp; &lt;a href="https://github.com/rails/rails/issues/29307"&gt;#29307&lt;/a&gt;) and &lt;a href="https://github.com/rails/rails/issues/45112"&gt;another open one&lt;/a&gt;.&lt;br&gt;&lt;br&gt;While slightly different all of them seem to focus on the same underlying issue of handling sudden disconnects. But the proposed solution for them isn't a pong response, instead it's a switch to client-initiated heartbeats.&lt;br&gt;&lt;br&gt;I was a bit surprised by the discussion leaning towards client-initiated heartbeats. Pong messages are a much simpler and less invasive solution which solves the same issue. So I read through all the discussions and did some research to understand what else client-initiated heartbeats bring to the table.&lt;br&gt;&lt;br&gt;The issues highlight the following as advantages of client initiated heartbeats:&lt;p&gt;&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Ability to change the heartbeat on the client-side, this would allow some clients to do heartbeats more frequently to detect if the connection broke more quickly&lt;/li&gt;&lt;li&gt;The client could measure latency - the time it takes for a message to do a round trip - to the server&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;There were two other features mentioned:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Dropped message detection&lt;/li&gt;&lt;li&gt;Reporting of the last-received message&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;These are technically doable by adding a &lt;a href="https://en.wikipedia.org/wiki/Lamport_timestamp"&gt;Lamport timestamp&lt;/a&gt; to each message, but they raise a new question "&lt;i&gt;&lt;em&gt;What do we do when we detect a dropped message?&lt;/em&gt;&lt;/i&gt;" which is application-level stuff and a major expansion of the framework, so I won't delve into these features.&lt;br&gt;&lt;br&gt;I did &lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/tree/82a851f0de0f8db414e3c8055fa28be4031ffd90"&gt;a proof of concept implementation of client-initiated heartbeats&lt;/a&gt;, before I found out &lt;a href="https://socket.io/"&gt;socket.io&lt;/a&gt; actually changed from client-initiated to server-initiated heartbeats.&lt;br&gt;&lt;br&gt;Reading through the &lt;a href="https://github.com/socketio/socket.io/issues/2769#issuecomment-639300952"&gt;GitHub issue&lt;/a&gt; where the switch was decided, I learned that &lt;a href="https://developer.chrome.com/blog/timer-throttling-in-chrome-88/"&gt;Chrome has started throttling setTimeout and setInterval for tabs that aren't in the foreground&lt;/a&gt; in 2021 to lower power consumption (extend battery life). When your app is in a tab that gets throttled the fastest that the heartbeat timer could run is once a minute. When that happens the client would send out heartbeats so slow that the server would think that it disconnected and it would close the connection, that would cause the client to reconnect, only to get disconnected again before the next heartbeat. Basically, the client would reconnect loop.&lt;br&gt;&lt;br&gt;Because of this, I abandoned client-initiated heartbeats and returned to the original pong message idea.&lt;br&gt;&lt;br&gt;There were a few things to iron out in the pong monkey patch. The most important one was forwards and backwards compatibility. The original implementation waited for the client to respond with a pong to switched the connection to expect pong messages. This allows clients that don't support pong responses to still function, while clients that know about pong responses get improved presence detection.&lt;br&gt;&lt;br&gt;This is forwards-compatible (&lt;i&gt;&lt;em&gt;client that don't send pong messages can connect to servers that expect them&lt;/em&gt;&lt;/i&gt;), and somewhat backwards-compatible (&lt;i&gt;&lt;em&gt;clients that do send pong messages can connect to servers that don't expect them&lt;/em&gt;&lt;/i&gt;) if you don't mind the error log messages that this will cause (&lt;i&gt;&lt;em&gt;it won't crash the WebSocket&lt;/em&gt;&lt;/i&gt;).&lt;br&gt;&lt;br&gt;But there is also a &lt;a href="https://en.wikipedia.org/wiki/Race_condition"&gt;race condition&lt;/a&gt; in this upgrade process, when a client connects and then immediately disconnects before they respond to the first heartbeat. When that happens the server is stuck thinking that the client doesn't support pongs even though they do, and will wait for the OS to close the connection and improved presence detection is lost. That’s exactly what I don’t want.&lt;br&gt;&lt;br&gt;The more elegant and robust solution would be for the client to signal to the server that it supports pongs when it’s opening the WebSocket. And for the server to respond back that pong messages have to be used, so that the client knows it should send them. That way I can mark the connection as expecting pongs right away, and the client can connect to servers that don't support pong messages.&lt;br&gt;&lt;br&gt;Luckly, WebSockets provide a mechanism for exactly that - it's called sub-protocols. When a client makes a HTTP request to open a WebSocket connection it can send a list of sub-protocols to the server in &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism#sec-websocket-protocol"&gt;a Sec-WebSocket-Protocol header&lt;/a&gt;. The sub-protocols have to be ordered by preference (the one you'd like the most first). The server chooses the first protocol from the list that it supports, and adds a Sec-WebSocket-Protocol header to it's response with just the chosen protocol. With that both the server and the client know exactly how to communicate over the WebSocket.&lt;br&gt;&lt;br&gt;ActionCable already uses Sec-WebSocket-Protocol both on the server and in the official client. The name of its protocol is actioncable-v1-json. Since the pong message is a change to the protocol, I created a new protocol revision - actioncable-v1.1-json - which expects a pong response to the server's heartbeat ping, and &lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/tree/9da4ce9df09de2b51e681f24df090aafe5949016"&gt;updated the monkey patches&lt;/a&gt;.&lt;br&gt;&lt;br&gt;The only thing left to do now was to open a PR to Rails.&lt;br&gt;&lt;br&gt;The first step towards that was to expose the latency metric to the application somehow. Up until now I only measured the latency and logged it in the connection object itself. It would be nice if I could somehow hook into that metric so that I can process it and store it on the device model somehow. For this I chose &lt;a href="https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html"&gt;ActiveSupport Notifications&lt;/a&gt;. I've added &lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/commit/e52488751c25dc4c2707835d5a6b60ff2d792c10"&gt;a new connection.latency notification&lt;/a&gt;, through which I can monitor, and respond to, the latency of any ActionCable connection.&lt;br&gt;&lt;br&gt;The second step was to improve the naming of methods. As I developed the monkey patches I changed terminology a few times so the names are all over the place. I settled on calling such connections &lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/commit/a3e4e682f93cdadf59fc532d19f1b30f5bd192da"&gt;half-open instead of dead, stale or expired&lt;/a&gt;. That seems to be the most apt description.&lt;br&gt;&lt;br&gt;The third step was to &lt;a href="https://github.com/monorkin/actioncable-presence-detection-demo/commit/e6d090199db396a9fcd5ef70d633068216692d2c"&gt;treat the connection as open if any message was received within the PONG timeout&lt;/a&gt;. After all, you can't receive messages if the connection is half-open, and if the server is processing a lot of incoming messages it might take too long to process a PONG message from some client and incorrectly think that the connection is half-open. There is also &lt;a href="https://github.com/rails/rails/pull/49168"&gt;an open PR to add the same behavior to the official JS client&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/monorkin/rails/tree/add-pong-response-to-heartbeat-ping-messages"&gt;The fourth step was to fork Rails, create a new branch from main, apply all the changes from the monkey patches to it, and add tests&lt;/a&gt;.&lt;br&gt;&lt;br&gt;The tests were a bit odd compared to other core Rails gems. In all other gems you only have to run bin/test to run the whole test suite, but in ActionCable you also have to run yarn pretest, bin/test and yarn test. This isn't well documented, I stumbled upon this by accident when I intentionally wrote a failing test.&lt;br&gt;&lt;br&gt;And the final step was to double check that I did everything in the &lt;a href="https://guides.rubyonrails.org/contributing_to_ruby_on_rails.html"&gt;contributing to Rails guide&lt;/a&gt;, and &lt;a href="https://github.com/rails/rails/pull/50039"&gt;opening a PR&lt;/a&gt;.&lt;br&gt;&lt;br&gt;With that out of the way, there is one more edge case that I've found. A device can suddenly lose connection, notice that, regain Internet service, and reconnect to the server, before the server closes the leftover half-open connection. When the server finally closes the half-open connection the device will be marked as offline in the database, even through it isn't.&lt;br&gt;&lt;br&gt;To solve that all I had to do is change the online boolean column to a connection_count integer column. Every time the device connects that column is incremented, and every time it disconnects it's decremented. If the count is zero, the device is offline, if it's positive the device is online. The only thing that I had to do in addition to that is to lock the row before updating the count so that a race condition doesn't change the count incorrectly.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class Device &amp;lt; ApplicationRecord&lt;br&gt;  has_many :online_status_changes, dependent: :destroy&lt;br&gt;&lt;br&gt;  scope :online, -&amp;gt; { where(connection_count: (1...)) }&lt;br&gt;  scope :offline, -&amp;gt; { where(connection_count: 0) }&lt;br&gt;  &lt;br&gt;  def came_online&lt;br&gt;    with_lock do&lt;br&gt;     old_count = reload.connection_count&lt;br&gt;     update!(connection_count: old_count + 1)&lt;br&gt;    end&lt;br&gt;    &lt;br&gt;    online_status_changes.create!(status: :online) if old_count.zero?&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def went_offline&lt;br&gt;	with_lock do&lt;br&gt;     old_count = reload.connection_count&lt;br&gt;     update!(connection_count: old_count - 1)&lt;br&gt;    end&lt;br&gt;    &lt;br&gt;	online_status_changes.create!(status: :offline) if connection_count.zero?&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;&lt;/pre&gt;&lt;p&gt;And that's it.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">This month I worked on tracking when a device connected and disconnected to a WebSocket backed with ActionCable. At first, this seemed like a simple problem to solve, but it turned out to be much more complicated.

I started with a simple solution, which was to mark a Device as being online in...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/34</id>
    <published>2023-10-10T09:45:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/rails-world-sPmrX4SDRCa5"/>
    <title>Rails World</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;It was Wednesday morning and I got up even earlier than I usually do because I had to be on a plain to Amsterdam in a few hours. I haven't been to Amsterdam since 2019 when I briefly meandered through it's streets on my way to EuRuKo in Rotterdam. This time I was going there for Rails World which was special to me since I've never been to Rails-specific conference before.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzMj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--6c58d48731fbb7a084c66a222667a54634fa0490" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBckFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--520bd03d5edae837a9d6bda589ff787b825a8259/railsworld_banner.jpeg" filename="railsworld_banner.jpeg" filesize="1379733" width="2385" height="2746" previewable="true" presentation="gallery" caption="Rails World banner"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Rails World banner" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDMyLCJwdXIiOiJibG9iX2lkIn19--a6648eb141e8dc2d318aba91ed371b897cef4e9b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/railsworld_banner.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Rails World banner
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Two brief flights and a train ride and I was at Amsterdam Centraal train station. It was a cloudy and windy day. I made my way to Beurs Van Berlage - a beautiful, old, ornate, red brick building with a tall clock tower, right in the heart of the city - to register for the conference.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzMz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--b814c3e7e5ab8981c1eecc2604668aa350d116c2" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBckVCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--16e00255343ca7b39066dd235051ae6b8b4fcdfd/railsworld_beurs_van_berlage.jpeg" filename="railsworld_beurs_van_berlage.jpeg" filesize="3896779" width="3024" height="4032" previewable="true" presentation="gallery" caption="Beurs van Berlage"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Beurs van Berlage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDMzLCJwdXIiOiJibG9iX2lkIn19--6487c9cf4e62b255a9bfb21b4dff6a1599fde67a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/railsworld_beurs_van_berlage.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Beurs van Berlage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Then I made my way to the Zoku hotel where I would be staying until Sunday. The hotel looked modern from the Booking.com listing, but the pictures don't do it justice. To check-in I had to take an elevator all the way up to the 6th floor, which turned out to be a rooftop terrace with small ponds, a glassed-in walkway to the lobby, and a garden on each side of the walkway. The view was amazing, the clouds had cleared a bit, and the sun was just starting to set over the city.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c41732fea168ec511471cca325a29383ae08aa44" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcklCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--f1d1d91660153c83e45a338261c437a14015e4c1/zoku_sunset.jpeg" filename="zoku_sunset.jpeg" filesize="2384375" width="2268" height="4032" previewable="true" presentation="gallery" caption="The sunset over Amsterdam as seen from the rooftop lobby of the Zoku hotel"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The sunset over Amsterdam as seen from the rooftop lobby of the Zoku hotel" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM0LCJwdXIiOiJibG9iX2lkIn19--179866348c74d2434ff629899d65a805d70283b2/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/zoku_sunset.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The sunset over Amsterdam as seen from the rooftop lobby of the Zoku hotel
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The room was very stylish. It had everything a regular apartment would have - a stove top, a kitchen sink, a dishwasher, a dining table, a TV, a speaker, a couch, an office desk, shelves full of books, a bed, plus a bathroom - but it was arranged in a very compact way which made it fun to just explore the room. For example, the bed is above the shelves and to get into it you have to pull out a set of stairs out of the shelves to be able to climb up. The office desk is in a cupboard&amp;nbsp; under the bed. In fact everything under the bed is storage space with various doors to it that act like closets. And there were a lot of quirky design touches like the office desk chair being made of concrete, the light switch in the bathroom being a plunger on a red string hanging from the ceiling, or the athletic rings hanging from the living room ceiling.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzNT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--e32b1f05dc8304475be6745e558427b2051a0b71" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBck1CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--747fabf5293d92747a843af819bb47fd1e960358/zoku_room.jpeg" filename="zoku_room.jpeg" filesize="1710227" width="3024" height="4032" previewable="true" presentation="gallery" caption="My room in the Zoku Hotel Amsterdam"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="My room in the Zoku Hotel Amsterdam" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM1LCJwdXIiOiJibG9iX2lkIn19--0f0cae69d1875a0f463a6d6703dd6f53c64e41ac/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/zoku_room.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      My room in the Zoku Hotel Amsterdam
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The next morning, at 8:30 am, I made my way back to Beurs Van Berlage. There weren't many people out an about, which was strange to me. In Zagreb, there would be a flood of people in the streets at this time of day.&lt;br&gt;&lt;br&gt;The venue was even more beautiful from the inside than from the outside. The red brickwork from the outside continued on the inside. There were four rooms, each of which was an atrium lit by sunlight coming through the colored glass ceiling held up by iron beams. Each room was three stories tall, and three of them had brick walkways on the first and second floor held up by arches and pillars.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzNj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--fc8b4485b3f92756b1808dcae1edd9dc63ae8269" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBclFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--ae23915971d0a443b1caf3b1303ed87761ae8d8d/beurs_van_berlage_ceiling.jpeg" filename="beurs_van_berlage_ceiling.jpeg" filesize="2851329" width="3024" height="4032" previewable="true" presentation="gallery" caption="The ceiling of the Beurs van Berlage"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The ceiling of the Beurs van Berlage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM2LCJwdXIiOiJibG9iX2lkIn19--c65b804651597856640807ad3989605c6e4f75b9/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/beurs_van_berlage_ceiling.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The ceiling of the Beurs van Berlage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The opening keynote was about to start, so I made my way to the Track 1 room to find a chair. The room had moody lighting and a stage as wide as the whole room. &lt;br&gt;&lt;br&gt;Amanda Perino, the executive director of the Rails Foundation, walked up on stage and give a captivating introduction. &lt;a href="https://rubyonrails.org/foundation"&gt;She explained what the role of the Rails Foundation is&lt;/a&gt;. Then she showed everywhere where people came from, the person that traveled the longest distance, and the person that traveled the shortest distance. People from all over the world came to Rails World. Finally she introduced the MCs, which in term introduced DHH.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--55d943e328638f3ff39cb39587a2b00a26700c54" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBclVCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f328c60bab5417cf0d79adbb0268729df133654/railsworld_stage.jpeg" filename="railsworld_stage.jpeg" filesize="2307337" width="3024" height="4032" previewable="true" presentation="gallery" caption="The stage on Track 1 of Rails World"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The stage on Track 1 of Rails World" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM3LCJwdXIiOiJibG9iX2lkIn19--d19e103ecaa93d3dd75f2c5f6da54fbdf7eb191b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/railsworld_stage.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The stage on Track 1 of Rails World
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;His keynote was a banger. It started off by introducing all the new features coming to Rails - solid_cache, solid_queue, Strada, Kamal, Propshaft, and Turbo 8 - and then switched to the concept of the “Renaissance Developer”, an analog to the Renaissance man or Polymath. A developer knowledgeable in many subjects - the backend, the frontend, deployments, and mobile apps - that can act as a one-person team. And Rails is exactly what enables such a developer. With money being tight as of late, these are the people we need to make great software.&lt;br&gt;&lt;br&gt;The idea of a Renaissance Developer deeply resonated with me. I always like to broaden my knowledge, learn new thing, learn how things work, how people solved some problems and what were their reasons behind making certain decisions, and then dive deep into topics that interest me most. But I was always met with advice like “&lt;em&gt;specialize in one thing, because that’s financially smarter&lt;/em&gt;” or “&lt;em&gt;don’t be a jack of all trades and a master of none&lt;/em&gt;”. This never stopped me from perusing knowledge, but it wore me down to hear that I was going in “&lt;em&gt;the wrong direction&lt;/em&gt;” every other day. Hearing DHH, and later people in the hallways, talk about the return of the Renaissance Man - the Renaissance Developer - was a breath of fresh air, inspiration, and motivation. I felt like I wasn't the crazy one after all.&lt;br&gt;&lt;br&gt;I went to the talk about Strada by Jay Ohms next. He gave an introduction into using Strada to build iOS and Android apps. Explained how it interacts with Turbo native and Rails/HTML, the design decisions behind it and trade-offs. Like, it being designed to easily deploy new versions of the app without going through AppStore reviews, and having backwards and forwards compatibility in mind. It seems to be a really solid way to build mobile apps. It doesn’t yet fully cover offline, but it seems that there is a plan how to expand into offline territory with local storage and IndexedDB. All in all a great talk.&lt;br&gt;&lt;br&gt;I learned how to hide UI elements using CSS and move them to native controls instead. You can do this because Strada updates a data attribute on the HTML element with a list of components it supports, so you can check if a component is enabled or not using square brackets and =~ selectors. Basically you implement a native method that adds a navbar button, for example, with which elements it should have and what it should send back when a button is pressed, from there you click the corresponding hidden UI element with JS and that’s it. It’s simple and that’s what I love about it. Everything we know and use in the web daily is translated over to mobile with a thin wrapper to control native UI elements.&lt;br&gt;&lt;br&gt;Next, I went to Jorge Manrubia’s talk about Making a difference with Turbo. His talk was great. He explained all the new features in Turbo 8 with a focus on Turbo Morph. Basically, they are building a calendar for Hey (if you watched closely during DHH’s keynote you could notice a calendar button in the top center of his Imbox) and rendering it with turbo streams would have been difficult so they looked for simpler solutions and developed Turbo Morph in the process.&lt;br&gt;&lt;br&gt;Generally, the easiest thing to do is to refresh a page when something updates - that's the old-school HTTP request-response way. But a full refresh is a jarring experience - the page flickers, you lose your scroll position, opened menus close again, CSS animations reset. &lt;br&gt;&lt;br&gt;The next easiest solution are turbo frames, which allow you to update the page in sections as you interact with that section. This is similar to a full page refresh though without most of the jarring side effects, but you have to keep track of when you want to break out of the frame and that each page you can navigate to from the frame has a frame of the same name in it. &lt;br&gt;&lt;br&gt;Finally, the hardest solution are turbo streams which are basically raw HTML manipulation events from the server. You have to know in which context you are sending them so you manipulate the HTML correctly. But they are the least jarring and feel like a native app.&lt;br&gt;&lt;br&gt;Now with &lt;a href="https://github.com/hotwired/turbo/pull/1019"&gt;Turbo Morph&lt;/a&gt; you have the same native look and feel as turbo streams with the ease of use of a full page refresh. Morph basically does a full page refresh, takes the new HTML and morphs the current HTML into the new one. This preserves CSS animations, scroll state, open menus (with a flag), and doesn’t flicker the screen. All of this is done using a library called &lt;a href="https://github.com/bigskysoftware/idiomorph"&gt;idiomorph&lt;/a&gt;. They were inspired by Phoenix’s LiveView does the same thing just using a different library.&lt;br&gt;&lt;br&gt;I’m hyped for the release of Turbo 8. Morph will make my life so much easier. I can basically delete most turbo stream responses at work and simplify the code a lot.&lt;br&gt;&lt;br&gt;Then came the lunch break. The food was amazing! The catering service deserves a lot of praise. There were many different options and all of them were delicious.&lt;br&gt;&lt;br&gt;After lunch, I went to Marco Roth’s talk about the future of Rails as a full-stack framework powered by Hotwire. His talk was also great. He focused more on the ecosystem around Hotwired, and how he came to Hotwired. He added a lot of nice user-friendly errors to Stimulus, like when you have a typo on a controller name or action it will tell you that instead of doing nothing. He also made an LSP for stimulus which looks amazing. It can suggest you completions specific to your app, like the exact controller, action, class, value or target name. He is also starting a weekly newsletter about Hotwired. And he started &lt;a href="https://hotwire.io"&gt;hotwired.io&lt;/a&gt; to aggregate and share all the cool thing in the ecosystem and community.&lt;br&gt;&lt;br&gt;Then I went to Alivija Rojas’ talk about an offline experience with a Rails powered PWA. It was a very insightful talk about how to build a progressive web app with Rails, that caches certain pages locally and stores any form submissions made with no Internet to be synced later when Internet service is available. This can be done with a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers"&gt;service worker&lt;/a&gt; with which you cache the index and new pages. Then you add a Stimulus controller on the form that checks if Internet service is available before submission and if it isn’t then you store the form data in &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API"&gt;Indexed DB&lt;/a&gt;. On the index page you check what you have in your local storage and render that. A neat idea.&lt;br&gt;&lt;br&gt;Next, I went to Xavier Noria’s talk about Zeitwerk’s internals. I liked his presentation style a lot and enjoyed the talk. I did read through &lt;a href="https://github.com/fxn/zeitwerk"&gt;Zeitwerk&lt;/a&gt; some time ago so it was nice to hear the explanation of each concept in the codebase and how some tricky situations are resolved when loading in classes and modules.&lt;br&gt;&lt;br&gt;For the last regular talk of the day I went to Breno Gazzola’s talk about &lt;a href="https://github.com/rails/propshaft"&gt;Propshaft&lt;/a&gt;. The goal of propshaft is to simplify asset serving a lot. It intentionally doesn't to anything besides generating digests and serving pre-processed files. This is in response to Sprockets, which do everything from integrating with Gulp to compiling coffeescript.&lt;br&gt;&lt;br&gt;Finally, the closing keynote by Eileen Uchitelle. It was the same keynote she gave at &lt;a href="https://m.youtube.com/watch?v=TKulocPqV38"&gt;RailsConf this year&lt;/a&gt;. A very inspiring talk, that motivated me to contribute to the framework more when I could. This talk also reminded me that I had an open PR to Rails that I forgot about.&lt;br&gt;&lt;br&gt;That was a wrap for day one so I walked back to my hotel room. Now there was a sea of people in the streets, seems like Amsterdam comes alive in the afternoon. &lt;br&gt;&lt;br&gt;There are a few things I’ve heard that I didn’t go to a talk to or that didn’t have a talk about them. First, solid_queue is essentially, or at least is inspired by, &lt;a href="https://github.com/bensheldon/good_job"&gt;good_job&lt;/a&gt; but will be database agnostic. And it's coming by the end of the year. Second, &lt;a href="https://github.com/rails/solid_cache"&gt;solid_cache&lt;/a&gt; is a database backed cache. The speed of retrieving data is worse than e.g. caching in Redis, but we are talking about 1.2ms instead of 0.8ms, nobody is going to notice that difference. The benefit of using the database as a cache is that your cache can live for much longer because SSDs are larger and cheaper for storage than RAM. Having longer lived caches means that you have to compute results less often which can dramatically decrease response times.&lt;br&gt;&lt;br&gt;The next day came around and I made my way back to the conference for an AMA with the Rails Core Team (10 out of 12 members were present). The AMA was lead by the creator of &lt;a href="https://ohmyz.sh/"&gt;OhMyZSH&lt;/a&gt;, Robby Russle. It was both inspiring and funny. Inspiring because the core team had nothing but words of encouragement towards the public, they want to help people contribute to Rails and know that some things can be daunring to beguinners which they could and are improving. And funny because of the answers, haggling with the host - especially between Aaron Patterson and Robby - and the bloopers. There was a wonderful moment when Robby asked all the contributors to rails to stand up, and about 10% of the audience stood up. Then he asked that anybody that has ever commented on a PR or similar to stand up, and another 20% of the audience stood up. Around 30% of the audience contributed to Rails in some way, which is a lot.&lt;br&gt;&lt;br&gt;Next were two talks that were both very interesting and which I wanted to attend but they were happening at the same time - that was kind of the theme of the second day.&lt;br&gt;&lt;br&gt;I went to the Rails and Ruby Garbage Collector talk by Peter Zhu first. I met the guy at EuRuKo last year and he seemed excited about this topic then so I figured this would be a great talk, and it was. He started off by introducing how GC works in Ruby. I wasn’t there for the rest of the talk but I heard that he created &lt;a href="https://github.com/Shopify/autotuner"&gt;a gem called autotuner&lt;/a&gt; to help you tune your Rails app’s GC.&lt;br&gt;&lt;br&gt;Midway through Peter Zhu’s talk I switched to the Powerful Rails features you might not know talk by Chris Oliver. This was a fun talk to listen to, Chris is an excellent presenter and speaker. He showed many interesting tips and tricks that you can use in Rails. So many that they’d warrant a post of their own to cover.&lt;br&gt;&lt;br&gt;I can’t wait for the videos to come so that I can watch both talks in full. &lt;br&gt;&lt;br&gt;I didn’t go to any of the next two talks that were next because I met a few people I knew from other conferences and some that I met yesterday so we chatted for a bit. This was also perfect timing since lunch was up next and we could get a head start on the food before queues form.&lt;br&gt;&lt;br&gt;After lunch, I went to Ryan Singer’s talk about Applying &lt;a href="https://basecamp.com/shapeup"&gt;Shape Up&lt;/a&gt; in the Real World. &lt;a href="https://www.feltpresence.com/srl/"&gt;He sells a course&lt;/a&gt; that seems to be a much more through version of this talk. Shape Up formalized most intuitions I already had about project management and taught me very helpful tools and processes for project management and development that are so simple and obvious that they are just beautiful, so I was looking forward to this talk. The talk was amazing, in my opinion it could be a keynote. &lt;br&gt;&lt;br&gt;He started off with introducing all the problems of modern project management and development and drew analogs from that. &lt;br&gt;&lt;br&gt;Like, “&lt;em&gt;working on tickets is like putting your project plan through a paper shredder&lt;/em&gt;”. I can’t state enough how strongly I agree with this analogy. I’m amazed that people don’t notice that most problems during a project occur because nobody sees the big picture and decisions are made basically in the dark.&lt;br&gt;&lt;br&gt;Next he had an analogy about cooperation which deeply resonated with me. “&lt;em&gt;Imagine renovating a room and spending moths laying out the new interior and particularly picking a lamp that will hang from the wall above the couch. Only to show the plan to the electrician and for him to tell you that there is no wire in that wall and that he’ll have to rip up the wall if you want to put the lamp there&lt;/em&gt;”. I’ve been so many times in a spot where somebody just threw something extremely hard to implement over the wall for me to solve. This was always made worse by working on tickets since often times I didn't notice a problem until I was in the middle of the project. Just asking the team how hard something is to implement is a huge improvement in cooperation between project management and development.&lt;br&gt;&lt;br&gt;There were so many nuggets of wisdom in this talk, it’s well worth the watch when the video comes out.&lt;br&gt;&lt;br&gt;Next, I went to Adam Wathan’s talk about &lt;a href="https://tailwindcss.com"&gt;Tailwind&lt;/a&gt;. It was an introduction to Tailwind with some tips and tricks along the side, all explained and shown through live coding examples. Respect for pulling that off, it was an excellent talk.&lt;br&gt;&lt;br&gt;Then I went to Irina Nazarova’s talk about making profit on OSS. This was a very interesting talk about which strategies you have if you want to make money on OSS and how you can bootstrap yourself. Among many other things, I learned that you can ask for sponsorship when a company asks for a feature on your project.&lt;br&gt;&lt;br&gt;For the last regular talk on Friday I went to Don’t call it a Comeback by Jason Charnes. It was a very inspiring talk about the comeback of Rails with a reminder that we have to work to keep it that way and not take it for granted. We have to help people learn Rails, make the guides better, improve documentation, make videos, record podcasts and more to keep it relevant.&lt;br&gt;&lt;br&gt;The closing keynote was Future of Developer Acceleration with Rails by Aaron Patterson. The talk started out with a lot of jokes poking fun at the opening keynote. &lt;br&gt;&lt;br&gt;Then he turned to his aggravated onion, a personification of layers of things he’s mad about. It was fun to hear him rant for a bit. He mostly focused on how rewriting things to other languages is way more complicated than improving the algorithm something uses and it doesn’t actually solve the fundamental issue - a slow sort is slow in Rust and in Ruby. That people think that interop between Rust and Ruby is safe when in fact it comes with new problems, like memory leaks. And concluded that we all should just enable YJIT since it gives a great performance boost for no extra cost.&lt;br&gt;&lt;br&gt;After that he turned to profiling. With profiling you can figure out which parts of your app are slow and should be optimized. He went over how a sampling profiler works and how to read a flame-graph and a call tree. Then he introduced &lt;a href="https://github.com/jhawthorn/vernier"&gt;Vernier&lt;/a&gt;, a sampling profile made by John Hawthorn and himself. Vernier is easy to integrate into Rails and comes with a nice UI to help you figure out what’s going on in your app.&lt;br&gt;&lt;br&gt;Finally, he turned to language servers. He created a language server for Rails that shows you model attributes, routes and other useful information right in your editor. This is now part of &lt;a href="https://github.com/Shopify/ruby-lsp-rails"&gt;ruby-lsp-rails&lt;/a&gt; which is in term an addon for for &lt;a href="https://github.com/Shopify/ruby-lsp"&gt;ruby-lsp&lt;/a&gt;. His goal is for Rails to have one standard, built-in, LSP so that developers have the best experience possible out-of-the-box.&lt;br&gt;&lt;br&gt;Except for the aggravated onion, these are things that he covered on his &lt;a href="https://www.youtube.com/@TenderlovesCoolStuff/streams"&gt;live streams&lt;/a&gt; in the past few months.&lt;br&gt;&lt;br&gt;And that was it. Just the 20th anniversary celebration was left. The lights in the rooms were dimmed, and there were celebratory cupcakes given out. I was so tired that I decided to go to the hotel room after 15min, but I heard that the party was a blast.&lt;br&gt;&lt;br&gt;I met a lot of new people, and a few old friends. &lt;br&gt;&lt;br&gt;I got to chat with Chris Oliver who’s podcast - &lt;a href="https://remoteruby.com"&gt;RemoteRuby&lt;/a&gt; - is my go to Ruby podcast. It’s a blast to listen to him, Jason and Andrew. I even got a hand crafted, artisan, limited edition podcast sticker.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzOD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f3f25399a76ef454c6bddbe8231c905df7b70b23" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcllCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5b48d68add0ee2ae7d87766e798033b3ff19123d/remoteruby_sticker.jpeg" filename="remoteruby_sticker.jpeg" filesize="3270178" width="4032" height="3024" previewable="true" presentation="gallery" caption="Remote Ruby podcast sticker"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Remote Ruby podcast sticker" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM4LCJwdXIiOiJibG9iX2lkIn19--c709295c123d3d374ffa814cb3c905ab7626d07e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/remoteruby_sticker.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Remote Ruby podcast sticker
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;I also met DHH and Rafael Franca. Though the only thing I mustered to say to them was “&lt;em&gt;Thank you both, Rails really changed my life&lt;/em&gt;”. I wanted to ask and say so much more but I was too tired to think at that point. And decided to stop there and go rest.&lt;br&gt;&lt;br&gt;Meeting these people really humanized them. Until then they almost seemed super human, but talking to them, meeting them in person, and just seeing them in the crowd really showed me that they are just regular people which is inspiring to me.&lt;br&gt;&lt;br&gt;I think Rails World was a huge success. The talks were so amazing that it was hard to pick which one to go to. Everything was well organized. The people were extremely nice. The volunteers/employees were super helpful. It wasn’t exorbitantly expensive (except for the hotels, but that's not their fault) and the location was great. Overall, just amazing. Amanda Perino did an outstanding job. I’m glad that I could attend it, and be there for Rails’ 20th anniversary - 20 years and still going strong!&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQzOT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c97ae3b0f1e43313531f7021eada19c58558e4f2" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcmNCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--7f96d8c3bc309cfcabd577f555128567e4933cad/rails_20th_anniversary_pin.jpeg" filename="rails_20th_anniversary_pin.jpeg" filesize="1572782" width="2483" height="2669" previewable="true" presentation="gallery" caption="Rails' 20th anniversary pin"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Rails' 20th anniversary pin" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDM5LCJwdXIiOiJibG9iX2lkIn19--648f62f75e1002f5b141e3096e82f3bbc0caee56/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/rails_20th_anniversary_pin.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Rails' 20th anniversary pin
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The next morning was a busy day. It was my last day before going back so I wanted to tour the city and enjoy the museums. The Rijksmuseum is my favorite museum, I’ve been to it three times already, but I wasn’t visiting it this time. This time I bought tickets for the Van Gogh museum since I’ve never been there before.&lt;br&gt;&lt;br&gt;The museum was huge. There are three floors full of his and his contemporaries' works. The first floor goes over his early life and how he got into painting. He used to be an art dealer, and at the age of 27 decided to become a painter.&lt;br&gt;&lt;br&gt;The second floor covers his life as a painter, his struggles, and how he searched for his style. I learned that he was influenced by Japanese art, which he and his brother collected and admired. He traveled around but found happiness in the countryside and the simple life of peasants. On this floor are the sunflowers, one of his most famous works.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f785a373ceae22a1a50f7d8dda1c38e00b3cbd1c" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcmdCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--2641030a0473b87b8031f0d8c0227372e59666f5/karla_with_the_sunflowers.jpeg" filename="karla_with_the_sunflowers.jpeg" filesize="2786919" width="3024" height="4032" previewable="true" presentation="gallery" caption="My girlfriend in front of the sunflowers by Van Gogh"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="My girlfriend in front of the sunflowers by Van Gogh" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQwLCJwdXIiOiJibG9iX2lkIn19--50b5a67c132def2ab18b93214ddca715de4ae526/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/karla_with_the_sunflowers.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      My girlfriend in front of the sunflowers by Van Gogh
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;On the last floor are the works immediately before his death, and the work of authors that he inspired. His painting career was relatively short lived with him committing suicide 10 years after becoming a painter. On this floor there were a number of his most famous paintings like the almond tree blossoms and the wheat fields.&lt;br&gt;&lt;br&gt;I was surprised to see that most paintings were covered by glass. This really sucks because Vincent put on very heavy brush strokes which gave his pictures a texture, but through the glass this is barely noticeable due to the glare. I mean, it makes sense to protect these paintings when people throw tomato soup on priceless works of art, but it still sucks.&lt;br&gt;&lt;br&gt;Something that I never noticed in prints of his work is how good he was at capturing depth. His paintings draw you in, there is a 3D effect going on that just isn’t visible in a photograph of the same painting. And the colors on the paintings seem much more vivid in person.&lt;br&gt;&lt;br&gt;There was a crossover exhibition going on too. Since Vincent was inspired by Japanese art, the Pokemon illustrators made a few Pokemon cards and paintings in his style. Needless to say, the gift shop was raided for anything and all Pokemon.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f62e235b1c5bb83bd1f9c148d7d23ec46fd10afc" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcmtCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--415e6bbd95dda4be8ad318453af75205aa49e52d/van_gogh_pikatchu.jpg" filename="van_gogh_pikatchu.jpg" filesize="2109815" width="2860" height="3705" previewable="true" presentation="gallery" caption="Pikachu with a hat, drawn in the style of Van Gogh"&gt;
&lt;figure class="attachment attachment--preview attachment--jpg"&gt;

      &lt;img alt="Pikachu with a hat, drawn in the style of Van Gogh" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQxLCJwdXIiOiJibG9iX2lkIn19--898e02fa1e9fb379df4b64b7732c99ce1e0e435b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--97c88e11642e16f54e1abf60d0fe43b8490e1f40/van_gogh_pikatchu.jpg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Pikachu with a hat, drawn in the style of Van Gogh
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Next up was the the De Hortis, Amsterdam’s botanical garden. It’s one of the best botanical gardens I’ve been to. The garden has 7 glass houses, an outdoor park, and bee hives. There is a tropical, sub-tropical, and desert glass house. The desert plans look like they came from an alien planet. While the tropical glass house had gigantic plants and was full of small brown singing frogs.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--613bbd5a41b125be237377c5132e2705dc26cc5a" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcm9CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--dd9c0a5a0353719faafce4030512f8962adde451/giant_liliypads.jpeg" filename="giant_liliypads.jpeg" filesize="3675699" width="3024" height="4032" previewable="true" presentation="gallery" caption="Gian lily pads from the tropical glass house at the De Hortis"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Gian lily pads from the tropical glass house at the De Hortis" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQyLCJwdXIiOiJibG9iX2lkIn19--eda6a86fc2cc6dbe34442c6b981d19f79bcc74ae/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/giant_liliypads.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Gian lily pads from the tropical glass house at the De Hortis
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;There was a very beautiful glass house with all sorts of plants.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0Mz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--b32af0113c6b1c0ee9ea3f7e52e68321dbc3a038" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcnNCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--ff2ccc1cd8a5f088398b9852b340cb2633eb07a3/glass_house.jpeg" filename="glass_house.jpeg" filesize="4969197" width="3024" height="4032" previewable="true" presentation="gallery" caption="A glass house at the De Hortis"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A glass house at the De Hortis" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQzLCJwdXIiOiJibG9iX2lkIn19--626a831e11b760fc19860fd5fb13d81576c0d142/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/glass_house.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A glass house at the De Hortis
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;And a glass house that served as a nursery for butterflies with dozens of them flying about pollinating plants.&lt;br&gt;&lt;br&gt;By now it was already late in the afternoon so I went back to the hotel to pack up.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0ND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--e31f641162bec4722e68bc4099a01e4de26c7ba9" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcndCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--98f9d10bbbffe3d0926319404b9231c881b4cc36/zoku_walkway.jpeg" filename="zoku_walkway.jpeg" filesize="2055083" width="2268" height="4032" previewable="true" presentation="gallery" caption="Walkway from the elevator to the lobby of the Zoku hotel"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Walkway from the elevator to the lobby of the Zoku hotel" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQ0LCJwdXIiOiJibG9iX2lkIn19--e8c6489e346a70f25f987b40e62feb653cbf147d/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/zoku_walkway.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Walkway from the elevator to the lobby of the Zoku hotel
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The next morning I made my way to Schiphol airport and then back to Zagreb.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ0NT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--d44c0fb5d32ef0cd73a37ba1baadcff8464d46ac" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcjBCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--69d0e2ad6d3eca122b26dfa393f5d8087e370352/shiphol_terminal_b36.jpeg" filename="shiphol_terminal_b36.jpeg" filesize="2729773" width="3024" height="4032" previewable="true" presentation="gallery" caption="Terminal B36 at Schiphol Airport"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Terminal B36 at Schiphol Airport" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDQ1LCJwdXIiOiJibG9iX2lkIn19--3533716cee2f09d4cc1a9f05737c3038f3c56f1a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/shiphol_terminal_b36.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Terminal B36 at Schiphol Airport
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">It was Wednesday morning and I got up even earlier than I usually do because I had to be on a plain to Amsterdam in a few hours. I haven't been to Amsterdam since 2019 when I briefly meandered through it's streets on my way to EuRuKo in Rotterdam. This time I was going there for Rails World which...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/33</id>
    <published>2023-08-28T05:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/hold-your-own-poison-ivy-MIpqar9OcB5v"/>
    <title>Hold your own Poison Ivy</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;After two years, this week I finally caved and changed my current employment title on LinkedIn from Software Engineer to Software Architect.&lt;br&gt;&lt;br&gt;This isn’t a position where I'm am supposed to do much programming - the thing I love most, and the thing I'm good at - but rather make high-level technical decision and manage people to achieve them. My problem with that, and any position like that, is best captured in the Croatian proverb &lt;em&gt;"It's easy to hold poison Ivy in somebody else's hands"&lt;/em&gt; (that's the SFW Americanized version, &lt;a href="https://translate.google.com/?sl=hr&amp;amp;tl=en&amp;amp;text=lako%20je%20tu%C4%91im%20kurcem%20po%20koprivama%20mlatiti&amp;amp;op=translate"&gt;here's the NSFW original&lt;/a&gt;).&lt;br&gt;&lt;br&gt;That proverb describes a decision made by someone who isn't involved in the execution of the decision, and doesn't suffer its consequences.&lt;br&gt;&lt;br&gt;I don't want to be the kind of person who just lobs some solution over the wall and lets other people deal with the fallout.&lt;br&gt;&lt;br&gt;I’ve had "managers" like that in the past, and it was hell working with them. Being told to do something that you know will cause problems, then bringing that up for discussion, explaining all the implications and new problems we will face, only to be shutdown with some hand-wavy explanation, and then having to deal with the consequences of that decision months or even years later is soul crushing. And if they can’t admit to their mistakes it can become burnout inducing.&lt;br&gt;&lt;br&gt;And I know that engineering isn't really about &lt;em&gt;solving problems&lt;/em&gt; as much as it’s about &lt;em&gt;displacing them&lt;/em&gt;. We are constantly trading one problem for one or more other problems. But we try to do so conscientiously, weight the pros and the cons, and pick a solution that produces problems that we can live with.&lt;br&gt;&lt;br&gt;The problem with Software Architects, or any purely managerial position, is that you aren't eating your own dog food, and therefore you don't live with the consequences of your decisions. So you produce &lt;em&gt;misfit solutions&lt;/em&gt;.&lt;/div&gt;&lt;blockquote&gt;The problem of displacement is compounded by the existence of designers—special people whose job it is to solve problems, in advance, for other people. Designers, like landlords, seldom if ever experience the consequences of their actions. In consequence, designers continually produce misfits. A misfit is a solution that produces a mismatch with the human beings who have to live with the solution. Some mismatches are downright dangerous.&lt;br&gt;&lt;br&gt;— &lt;strong&gt;Donald C. Gause&lt;/strong&gt;, &lt;strong&gt;Gerald M. Weinberg&lt;/strong&gt; in &lt;a href="https://www.amazon.com/Are-Your-Lights-Figure-Problem/dp/0932633161"&gt;Are Your Lights On?: How to Figure Out What the Problem Really Is&lt;/a&gt;&lt;/blockquote&gt;&lt;div&gt;After two years, I’m confident that I’ve managed to avoid becoming that kind of “manager” by holding my own poison Ivy, and by helping others make decisions for themselves.&lt;br&gt;&lt;br&gt;When I'm presented with a problem, I try to implement a proof of concept solution my self. Not a demo, held up with glue and duct tape; but a proper minimal implementation. Then I present the solution to my coworkers and gather feedback. That way I can feel the consequences of my solutions.&lt;br&gt;&lt;br&gt;When someone comes to me with a problem, we go over all the possible solutions that we can come up with, then we discuss the pros and cons, and that's it. I let person decide for themselves which solution they will go with. All I ask of them is to tell me which one they chose. The point is to shine some light on problems that the person maybe couldn't see on their own, and point out some bigger-picture things that they might not know about. That way they can make an informed decision and choose the solution with which they can live with - so there aren't any misfit solutions.&lt;br&gt;&lt;br&gt;This isn't a silver bullet by any means. Bad decisions still get made, I fuck up, my coworkers fuck up. But we learn, fix stuff, build trust, and improve.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">After two years, this week I finally caved and changed my current employment title on LinkedIn from Software Engineer to Software Architect.

This isn’t a position where I'm am supposed to do much programming - the thing I love most, and the thing I'm good at - but rather make high-level...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/32</id>
    <published>2023-07-31T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/rent-in-zagreb-is-hell-lxSb30fFZmQq"/>
    <title>Rent in Zagreb is hell</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Last month my landlord called me and told me that I had to move out because he was going to sell the apartment I live in.&lt;br&gt;&lt;br&gt;In a way, this came as a blessing. I wanted to move out of that place by the end of the year either way. But at the time I had no clue what hellscape finding an apartment in Zagreb had become since I last moved. This is a cautionary tale of what to look out for and what to avoid when renting in Zagreb.&lt;br&gt;&lt;br&gt;I immediately started looking for a new place on Njuskalo (a local classified ads site).&lt;br&gt;&lt;br&gt;The first thing I noticed was that rent has gone up by about 25%. I read about that in the news, so that didn’t surprise me too much. But what the news articles didn’t say is that for 3 bedroom apartments the price went up by 100% or more, which sucks because I always go for a 3 bedroom apartment so that I can have an office. Having the ability to clearly separate my work and my life environment is important to me.&lt;br&gt;&lt;br&gt;After some searching I found a few reasonably priced apartments and arranged to go see them.&lt;/p&gt;&lt;h2&gt;Chapter 1: Paradigms &amp;amp; Marketing&lt;/h2&gt;&lt;p&gt;Seems like in the years since my last move most landlords took a philosophy course and decided to tackle the age old question of &lt;i&gt;&lt;em&gt;“What is the ground floor of a building anyway?”&lt;/em&gt;&lt;/i&gt;.&lt;br&gt;&lt;br&gt;The first apartment I went to see was supposed to be on the ground floor of a small two story building. But upon entering the building - from the street - the real estate agent took me down a stairwell to a floor with a single door, opened it, and we stepped into the apartment.&lt;br&gt;&lt;br&gt;“Very weird ground floor” I told him, “It’s almost 3 meters underground”. The real estate agent responded without hesitation with a happy but somewhat hostile sounding tone “Depends on what you consider the ground floor to be. This apartment is on the ground, and it has windows to the outside which are at waist height, it even has an exit to the garden. By all means this is the ground floor.”&lt;br&gt;&lt;br&gt;I wasn’t in the mood to discuss paradigms like Physics professors defending the foundation of their lives work at some conference, so I sad my goodbyes and left.&amp;nbsp;&lt;br&gt;&lt;br&gt;Little did I know that this would just be the first in a series of apartments that were on the ground floor while at the same time at least one stairwell below the entrance and 1+ meters underground.&lt;br&gt;&lt;br&gt;There were two camps of people; one believes that the ground floor is what's at the main entrance and everything above or below that is either + or - 1 floor, while the other camp believes that the ground floor is everything that touches the ground.&lt;br&gt;&lt;br&gt;I am a firm believer in the ground floor is at the main entrance, but I was thrown into some bizarre world out of Kafka's fever dreams and just didn't know it yet.&lt;br&gt;&lt;br&gt;How could a fundamental fact like this be subject to relative interpretation? I tried to see the the other camp's perspective - to understand their paradigm - but no matter how I sliced it this was just complete madness.&amp;nbsp;&lt;br&gt;&lt;br&gt;If everything that touches the ground is the ground floor then some buildings would have two ground floors or more - one on the street level and another in the basement - which made no sense at all. How would you explain to someone where you live?&amp;nbsp;&lt;br&gt;&lt;br&gt;How would someone know by looking from the street how many stories the building had, or even if they entered it? There would have to be a label on the building that warned visitors that there were more stories below ground just for them to be able to find the right floor without a map.&amp;nbsp;&lt;br&gt;&lt;br&gt;I already imagined a conversation like this: “See George, if you are one of the street-entrance-is-the-ground-floor folk, then I live on the 2nd floor; but if you are one of the-lowest-apartment-on-the-ground-is-the-ground-floor folk then I must tell you that there are two stories below street level in this building so I live on the 4th floor; but, on the other hand, if you are the there-are-multiple-ground-floors kind of guy then I', on the 2nd floor counting from the street entrance”.&lt;br&gt;&lt;br&gt;Of course this wasn’t madness, this was just marketing.&amp;nbsp;&lt;br&gt;&lt;br&gt;Dugouts, and basement apartments aren’t hip in Zagreb anymore. Nobody in their right mind would pay 800 Euro or more to live in a damp basement, have to listen to 3 families living above their head, and battle mold all day long. When for 850 euro you can live on the 1st or 2nd floor of the same building, or buy your own apartment (while interest rates were low).&lt;br&gt;&lt;br&gt;That’s a problem for real estate agencies as they are paid by commission, and for landlords as they want to get the most out of their property. But Zagreb has a real estate problem - price per square meter is comparable to Berlin, yet over half the buildings are 70 to 130 years old, and most of them are in a bad shape from being neglected for years due to co-owners not being able to agree on anything.&lt;br&gt;&lt;br&gt;(seriously though, in my last building the co-owner's counsel couldn't agree on the color of the front gate for 2 months and then they just gave up and let the gate rust)&lt;br&gt;&lt;br&gt;So realtors and landlords shifted the paradigm and the basement suddenly became the ground floor. In a way this is just a white lie. If you really like the apartment and the price is right for you, all they did was bend reality a bit to get you to see the apartment. But in the process they are gas-lighting everybody and inflating housing prices.&lt;/p&gt;&lt;h2&gt;Chapter 2: Lies, lies, lies!&lt;/h2&gt;&lt;p&gt;No matter which apartment I went to see all of them were built after 1963. This was odd for Zagreb - a city known for it’s extremely old buildings.&lt;br&gt;&lt;br&gt;For apartments in Zagreb, and Croatia in general, knowing that a building was built after 1963 is very important due to earthquake code.&lt;br&gt;&lt;br&gt;&lt;a href="https://en.wikipedia.org/wiki/1963_Skopje_earthquake"&gt;Before 1963 the code didn’t cover earthquakes of magnitude 6 or 7&lt;/a&gt;. And with a recent 5.4 magnitude earthquake in 2020 that severely damaged a lot of buildings, people don’t want to pay as much for a building that could collapse on their head at any moment.&amp;nbsp;&lt;br&gt;&lt;br&gt;So landlords started lying, and over night all buildings got 50 years younger. A case so curious that Benjamin Button would be surprised.&amp;nbsp;&lt;br&gt;&lt;br&gt;Some didn’t outright lie and just said that they don’t know when the building was built. This is very odd since they invested several hundred thousand euros into that apartment, one would expect them to do due diligence.&amp;nbsp;&lt;br&gt;&lt;br&gt;But some outright lie. One apartment claimed to be build in 1965, but on Google Street View it was clearly visible that the facade had a stamp with the name of the architect that designed the building and the build year - which was 1898.&amp;nbsp;&lt;br&gt;&lt;br&gt;In my opinion this should be a criminal offense, as this lie could get someone killed when they thought that they were perfectly safe (and that they were paying for that safety).&lt;br&gt;&lt;br&gt;This was the worst lie I encountered, but it wasn’t the only one.&amp;nbsp;&lt;br&gt;&lt;br&gt;People lied about the building’s construction. For example, wooden beam floors and ceilings become concrete floors and ceilings. One is paper thin and through it you can hear your upstairs neighbor like they are your roommate, while the other blocks all sound but direct impacts on the floor above.&amp;nbsp;&lt;br&gt;&lt;br&gt;Wooden windows become PVC windows. Two single pane windows become double pane windows. And stuff like that.&lt;br&gt;&lt;br&gt;The funniest lie I saw was a "Modern *rustic* apartment in the center" which was actually an apartment in which somebody's grandmother lived until she passed away (recently) and they decided to rent it out with her furniture. This is usually OK, but the furniture was from the 60's, they charged for it like the place was freshly renovated, and to explain that they just slapped on "rustic" in the name of the ad like it would magically explain that the cabinets were made before nuclear reactors were invented and are starting to fall apart.&lt;/p&gt;&lt;h2&gt;Chapter 3: *Upkeep not included&lt;/h2&gt;&lt;p&gt;For a number of years there has been a growing number of landlords that don’t take on the upkeep but instead expect the tenant to pay it. They claim that upkeep is a utility like water or electricity so they exclude it from the rent.&lt;br&gt;&lt;br&gt;Upkeep can be 30 to 150 euro or more, and it can change at any time. So an 850 euro apartment plus upkeep can be as expensive, if not more, as a 1000 euro apartment with upkeep included.&lt;br&gt;&lt;br&gt;But the real problem is that upkeep isn’t like a utility. I, as a tenant, can control which utilities I use and how much I use them. And I &lt;b&gt;&lt;strong&gt;consume&lt;/strong&gt;&lt;/b&gt; the utility that I pay for.&lt;br&gt;&lt;br&gt;But I can’t consume the upkeep and I can’t influence how the upkeep is spent. Only the owner of the apartment can do that.&lt;br&gt;&lt;br&gt;This is like taxation without representation. The upkeep can be 30 euro today, and my landlord lobbies for a new facade for the building which then raises the upkeep to 150 euro and I'm stuck with the bill for it. I don’t get to keep the facade when I move out, and I don’t get to vote on if the building gets a new facade or not. So why should I pay for the upkeep?&lt;br&gt;&lt;br&gt;This trend is just another way for landlords to reduce their risk and offload it to the tenant. It ensures that the landlord gets a constant sum of money every month, regardless of what the other co-owners of the building decide to do or if something in the building breaks. It also enables them to improve their property on the tenant's expense without having to renegotiate rent.&lt;/p&gt;&lt;h2&gt;Chapter 4: Parking in my apartment&amp;nbsp;&lt;/h2&gt;&lt;p&gt;A few apartments seemed quite small for their advertised size. For example one 60 square meter apartment seemed more like a 40 square meter apartment to me.&amp;nbsp;&lt;br&gt;&lt;br&gt;So I asked the real estate agent what was going on, and he explained that the two out door parking spaces (8 square meters each) count towards the total area of the apartment.&lt;br&gt;&lt;br&gt;Turns out that this is completely normal, and has been like this since forever. But to me this is utterly insane.&amp;nbsp;&lt;br&gt;&lt;br&gt;A parking space isn’t the same as a room in an apartment or in a garage. And I can’t believe that this requires explaining.&amp;nbsp;&lt;br&gt;&lt;br&gt;For one you can’t leave stuff unattended in a parking space. Just imagine leaving your laptop in a parking space and then complaining to the police that it was stolen while you weren't there. Or imagine one of your neighbors grilling on their parking space, or building a shed, or something like that...&lt;br&gt;&lt;br&gt;But it seems I’m the crazy one here. Just keep this in mind when comparing two apartments by their listed size.&lt;/p&gt;&lt;h2&gt;Chapter 5: Contracts and Xenophobia&amp;nbsp;&lt;/h2&gt;&lt;p&gt;I was down to a few apartments and started negotiating lease contracts when I learned that another major thing has changed in the years since my last move - contracts.&lt;br&gt;&lt;br&gt;Before, it was normal to have a lease contract that basically said that you are leasing the place, that you have to give one month’s rent in advance as insurance, that you will return the place in the state you found it, and that by the 15th of each month you have to pay rent and utilities to the landlord.&lt;br&gt;&lt;br&gt;Today, you have to sign a contract which gives the landlord the right to seize money from your bank account (or anything you own in case there isn't enough) in case you miss payment or owe them anything. This is in addition to a one month’s rent as insurance.&lt;br&gt;&lt;br&gt;When I consulted a lawyer they warned me that the “in case you miss payment” is arbitrary and that the landlord can actually seize any amount whenever they want as long as the contract is active and they have semi-valid evidence to do so.&amp;nbsp;&lt;br&gt;&lt;br&gt;A government body does the seize in the name of the contract, they would give me 7 days to produce evidence that the seize isn’t valid after which they would just deduct money from my account and send me a processing fee for it. The problem here is that producing evidence is fairly easy for both sides. Just think about it, how would you prove that you are missing money? Or that it wasn’t paid in cash? Or deducted for a repair or other? So most cases end up in favor of the landlord and the tenant has to sue the landlord to reclaim the money, which can take years.&lt;br&gt;&lt;br&gt;This if of course completely insane. Who in their right mind would allow a random person they don’t know unrestricted access to everything they own for the privileged to live in an apartment that they happen own?&lt;br&gt;&lt;br&gt;When I asked why this was needed I got just one reply from all landlords “We had bad experiences with foreigners“.&amp;nbsp;&lt;br&gt;&lt;br&gt;So… xenophobia and lies.&amp;nbsp;&lt;br&gt;&lt;br&gt;Obviously I’m not a foreigner yet I’m still required to sign such a contract. But even if I was, how would this be any insurance? The Croatian government has only jurisdiction in Croatia - if a Canadian signs the contract, moves in, skip a months rent, then moves back to Canada, you’d still have to sue them and drag me to court to get your money.&lt;br&gt;&lt;br&gt;So this is again just another new way of shifting risk from the landlord to the tenant.&lt;br&gt;&lt;br&gt;Things can happen, people lose jobs, people get sick, people die, but the landlord doesn’t care about any of this; they just want a sure way to get their rent and this contract ensures it.&lt;br&gt;&lt;br&gt;Another interesting addition to some contracts was a non-termination clause. In other words the contract can’t be terminated by the tenant, if they decide to move away before the lease expires they have to pay the lease in full for the duration of the contract. And, of course, leases are non transferable meaning that you can’t find them another tenant to replace you, and neither can you sub-lease your apartment.&lt;br&gt;&lt;br&gt;This is slavery, and should be illegal. I can’t know going into a lease what will happen 9 months down the line. And I can’t know if the landlord lied to me before I move into the apartment, or what the neighbors are like. Yet I am expected to lock in for at least a year of rent, give unlimited access to everything I own, based on a 15min tour of the apartment… insanity…&amp;nbsp;&lt;br&gt;&lt;br&gt;This isn't normal, and this was never normal. Avoid such contracts at all cost. If you really like the place offer up two month's rent as insurance and see if they go for it.&lt;/p&gt;&lt;h2&gt;Chapter 6: Living in a forest&lt;/h2&gt;&lt;p&gt;My dream is to one day live in a house in a forest, or at least very close to one.&lt;br&gt;&lt;br&gt;After a month of searching for an apartment I found one that’s in a forest, in a new building, made out of concrete, has a balcony, has an acceptable price, has a bog standard lease, has upkeep included, and is in a quiet neighborhood!&lt;br&gt;&lt;br&gt;But it only has two bedrooms, and it’s 40min by foot to the closes store (a very European problem since until now I didn't have a reason to own a car).&lt;br&gt;&lt;br&gt;I decided to go for it, and I’m so glad I did.&lt;br&gt;&lt;br&gt;Now I enjoy my morning tea looking at the forest and the fog that emanates. The cold night air carries scents of leaves and lulls me to sleep. It's much easier to recharge and take a break than it was in a city apartment.&lt;br&gt;&lt;br&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzQyOT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ef118b877e9024608e3683891e1a458eb2d32fac" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBcTBCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--110ac084205b627a093b83eb29b3e6fa5a30706a/IMG_4052.jpg" filename="IMG_4052.jpg" filesize="258483" width="1200" height="1600" previewable="true" presentation="gallery" caption="View from the balcony at night"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpg"&gt;

      &lt;img alt="View from the balcony at night" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NDI5LCJwdXIiOiJibG9iX2lkIn19--494e652dfa96c15d08f9036d0d2f0d1bc0fe8eeb/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--97c88e11642e16f54e1abf60d0fe43b8490e1f40/IMG_4052.jpg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      View from the balcony at night
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;p&gt;&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last month my landlord called me and told me that I had to move out because he was going to sell the apartment I live in.

In a way, this came as a blessing. I wanted to move out of that place by the end of the year either way. But at the time I had no clue what hellscape finding an apartment in...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/31</id>
    <published>2023-04-26T06:01:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/corporate-newspeak-v2E1RTcmIUfi"/>
    <title>Corporate newspeak</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;blockquote&gt;If thought corrupts language, language can also corrupt thought&lt;br&gt;&lt;br&gt;- George Orwell&lt;/blockquote&gt;&lt;div&gt;One thing that annoyed me anywhere I worked was that people limit themselves to a narrow subset of English. A subset that's full of buzzwords and phrases that sound impressive but lack meaning. It's a subset designed to limit your ability to say anything that could be misinterpreted, and anything that expresses any emotion beyond a notion of happiness or dissatisfaction. This language couldn't mimic George Orwell's Newspeak more even if it tried, and therefore I call it "Corporate newspeak".&lt;br&gt;&lt;br&gt;What annoys me is that speaking with someone in corporate newspeak feels like we are speaking in memes. We aren't saying what we think, we aren't being honest with one another, instead we are communicating in vague concepts and ideas. We are obscuring our though instead of clarifying them.&lt;br&gt;&lt;br&gt;Take "We don't have the bandwidth" as an example. What does that sentence mean? It could be "The network switches in our server farm can't handle any more network traffic", or it could be "There isn't anyone available to work on this project in the next month, we would have to pause another project", or "Your project isn't something I think we should invest time in right now". There is no way to know without asking for clarification. But that will probably yield an answer with more newspeak and more buzzwords. Miscommunication is the goal of newspeak. If no one knows exactly what was said then there is a lot of room to avoid any kind of conflict.&lt;br&gt;&lt;br&gt;Another thing that annoys me with newspeak is that it makes everything sound insincere, hollow, and boring because the vocabulary is made up of cliches. Take "We have an exciting announcement. From next quarter our Widget 3000 will come in two new colors - blue and green" as an example. Is that really an exciting announcement? I mean, it could be. But the excitement is undermined by every other announcement that was made that day that was also "exciting". If everything is exciting then nothing is exciting - it rings hollow.&lt;br&gt;&lt;br&gt;Some find newspeak to be sincere, but I don't and maybe that's a culture difference. I find only language that I would use every-day sincere. And I don't know a single person that uses newspeak to talk to a friend, or a cashier, or a random person in the street. Maybe that's because of where I grew up? In Croatian the only difference between how you would talk to your friend and a business client is that you'd us the formal "You" instead of the regular "you" and you'd use "Greetings" instead of "Hi" when addressing them.&lt;br&gt;&lt;br&gt;Communicating in memes slowly becomes thinking in memes. The cliches drown out any authenticity. Thoughts become blurry and hard to explain to others. That's a very high price for conflict avoidance.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">“If thought corrupts language, language can also corrupt thought

- George Orwell”

One thing that annoyed me anywhere I worked was that people limit themselves to a narrow subset of English. A subset that's full of buzzwords and phrases that sound impressive but lack meaning. It's a subset...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/30</id>
    <published>2023-04-05T08:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/from-markdown-to-actiontext-jAIFShaUCI8Q"/>
    <title>From Markdown to ActionText</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;I started this iteration of my blog because I grew dissatisfied with Medium. Like anyone migrating from any one platform to another I requested an export of my blog posts from them and got back a ZIP file containing HTML files.&lt;br&gt;&lt;br&gt;The first thing that stood out was a lack of any media files - there were no images or gifs in the export. Opening the HTML files revealed that they contained the exact same HTML as was served by Medium.com. The source of the images and gifs pointed to Medium's servers. And the HTML itself had a lot of formatting and hidden elements specific to Medium and SEO.&lt;br&gt;&lt;br&gt;I spent a whole day downloading images and cleaning up the files into something that could be imported somewhere else. I decided to convert my blog posts into Markdown - the pendulum had swung too far in the opposite direction. That way I could easily import them anywhere later. I fantasied about&amp;nbsp; writing blog posts directly in my text editor with no distraction like headings, italics, bold, fonts - just my thoughts and text. And it was fun to develop a markdown rendering pipeline for the blog!&lt;br&gt;&lt;br&gt;After a few months I gave up on the fantasy of writing in a text editor. While markdown is great, having a few simple options for editing text and being able to see the result right away is much more pleasing than knowing that # will eventually turn into a h1 element.&lt;br&gt;&lt;br&gt;I started writing and editing articles in Notion, exporting them to markdown, adjusting them for my blog, and then uploading them here. This was unnecessarily complicated, but it gave me the ability to write wherever I went (I like to go for a walk, think, then sit down on a bench when I figure something out and write it down).&lt;br&gt;&lt;br&gt;Now I had a different problem - editing an article after it was published. With this process I had to first update the version in Notion, export it, adjust it again, and upload it again. This was annoying when I started publishing more often and therefore catching less typos in editing.&lt;br&gt;&lt;br&gt;This convoluted process eventual led me to stop publishing articles here. Then a few weeks ago I had the urge to write and publish something. But the thought of having to go through this publishing process made me put it off. This week I decided to correct this pendulum swing and migrate to ActionText to make things as simple as possible.&lt;br&gt;&lt;br&gt;ActionText is a framework within Rails that enabled rich text editing and presentation. Adding it took no time at all, but migrating data to it took a whole day. There are many opinionated decisions in the framework with which I butted heads in the process.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The first "problem"&lt;/strong&gt;&lt;/b&gt; I ran into were attachments. ActionText uses Rails' own ActiveStorage framework for uploading, processing and serving attachments, while the blog used Shrine (because I like Shrine). But the way I used Shrine was very reminiscent of how ActiveStorage works, so instead of reinventing the wheel further I decided to migrate over.&lt;br&gt;&lt;br&gt;This wasn't an issue and was as simple as iterating through all &lt;b&gt;&lt;strong&gt;Article::Attachment&lt;/strong&gt;&lt;/b&gt; records, taking their attachment, calling &lt;b&gt;&lt;strong&gt;to_io&lt;/strong&gt;&lt;/b&gt; on it and then passing that to &lt;b&gt;&lt;strong&gt;ActiveStorage::Blob.create_and_upload!&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;blob = ActiveStorage::Blob.create_and_upload!(&lt;br&gt;  io: attachment.attachment.to_io,&lt;br&gt;  filename: attachment.attachment_data&amp;amp;.dig("metadata", "filename"),&lt;br&gt;  content_type: attachment.attachment_data&amp;amp;.dig("metadata", "mime_type"),&lt;br&gt;  identify: false&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The second "problem"&lt;/strong&gt;&lt;/b&gt; I ran into was paragraph support. ActionText doesn't use paragraphs (p elements) for text, instead it puts all text into a single div element and breaks it into "paragraphs" using line breaks (br elements). This was a problem because I wanted to use &lt;a href="https://tailwindcss.com/docs/typography-plugin"&gt;Tailwind's Typography plugin&lt;/a&gt; and its &lt;b&gt;&lt;strong&gt;prose&lt;/strong&gt;&lt;/b&gt; class - which expects paragraphs as p elements. After fighting with the framework to make it work with p elements I ran across &lt;a href="https://github.com/basecamp/trix/issues/202#issuecomment-461166895"&gt;a GitHub issue where the reasoning behind using a div was explained&lt;/a&gt; which made me realized that it was far easier to write a bit of custom CSS than to make paragraphs work with ActionText in all browsers.&lt;br&gt;&lt;br&gt;Without the explanation from the GH issue the decision to use divs seemed completely arbitrary and I wanted it to work with Tailwind. It would have been nice to have this explanation in the Readme.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The third problem&lt;/strong&gt;&lt;/b&gt; was converting my previously rendered markdown into ActionText's HTML format (using a div and br elements) and embedding images into it. Though this turned out to be easier than I initially thought. A simple script and some Nokogiri magic and everything work like a charm.&lt;/p&gt;&lt;pre data-language="ruby"&gt;Nokogiri.HTML(article.rendered_markdown).tap do |doc|&lt;br&gt;  # Remove the article's title&lt;br&gt;  doc.css("h1").first&amp;amp;.remove&lt;br&gt;  &lt;br&gt;  # Convert all paragraphs into a DIV with BRs&lt;br&gt;  content = doc.css("p").first&lt;br&gt;  content.name = "div"&lt;br&gt;  node = content&lt;br&gt;  while (node = node.next_sibling)&lt;br&gt;    content.inner_html += "&amp;lt;br&amp;gt;#{node.inner_html}"&lt;br&gt;    node.remove&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  # Convert images to attachments&lt;br&gt;  doc.css("img").each do |node|&lt;br&gt;    node.name = "action-text-attachment"&lt;br&gt;  end&lt;br&gt;  &lt;br&gt;  article.update!(content: doc.to_s)&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The fourth problem&lt;/strong&gt;&lt;/b&gt; was the &lt;a href="https://github.com/basecamp/trix/issues/539"&gt;lack of support for tables&lt;/a&gt;. But I decided to use Judo here and just &lt;a href="https://github.com/basecamp/trix#inserting-a-content-attachment"&gt;embed the tables as content attachments&lt;/a&gt;, then take screenshots of them and embed them as images. This isn't ideal, but it works and it's what I used to do on Medium. I intend to add table support at some point, but currently only two articles use tables and spending a few days on this wasn't worth it.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The fifth "problem"&lt;/strong&gt;&lt;/b&gt; was code block highlighting. At first I though this would be a significant undertaking that would require me to hook into ActionText, but in the end I used the Judo approach again and simply created a helper that changes the HTML before rendering.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module RichTextHelper&lt;br&gt;  def transform_rich_text(rich_text, transforms: nil)&lt;br&gt;    if transforms.blank?&lt;br&gt;      transforms = methods&lt;br&gt;        .select { |name| name.to_s.ends_with?("_rich_text_transform") }&lt;br&gt;        .map { |name| name.to_s.gsub(/_rich_text_transform$/, "") }&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    document = Nokogiri.HTML(rich_text.to_s)&lt;br&gt;&lt;br&gt;    transforms.reduce(document) do |doc, transform|&lt;br&gt;      public_send("#{transform}_rich_text_transform", doc)&lt;br&gt;    end.to_s&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  # Makes code blocks look prettier / more readable&lt;br&gt;  def highlight_code_blocks_rich_text_transform(document)&lt;br&gt;    document.css("pre").each do |code_block|&lt;br&gt;      code = code_block.text&lt;br&gt;      formatter = Rouge::Formatters::HTML.new&lt;br&gt;      lexer = Rouge::Lexer.guess({ source: code })&lt;br&gt;      code_block.inner_html = formatter.format(lexer.lex(code))&lt;br&gt;      code_block["class"] = [code_block["class"], "highlight"].select(&amp;amp;:present?).join(" ")&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    document&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;# then in some view&lt;br&gt;&amp;lt;%= transform_rich_text(@article.content) %&amp;gt;&lt;/pre&gt;&lt;p&gt;Though my initial idea of hooking into ActionText led me to read it's source code, because of which I'm now much more familiar with the framework. So this detour wasn't a waste time.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The final "problem"&lt;/strong&gt;&lt;/b&gt; were link previews. That's the feature I love the most in this iteration of the blog and wanted to have it in action text. At first I through that I'd have to extend the helper from before but then it dawned on me that I could inject everything needed for link previews through Stimulus. I liked that approach in this case because the previews are a "frontend" feature and they don't work if someone disabled JS or blocks it. But I'm still unsure it that was a good idea and I might move the code to setup previews into the helper.&lt;br&gt;&lt;br&gt;All in all, I love using ActionText and I'm glad I fixed my over-correction. Having an editor baked into the blog makes it so much easier to edit (at home and on the go). And since the HTML format is well documented and simple I can still convert it to some other format if need be.&lt;br&gt;&lt;br&gt;ActionText was easy to integrate, and with hindsight I can say that it was also easy to migrate to it.&lt;br&gt;&lt;br&gt;&lt;i&gt;&lt;em&gt;P.S. while reading through ActionText's source and issues &lt;/em&gt;&lt;/i&gt;&lt;a href="https://github.com/basecamp/trix/issues/626#issuecomment-800338265"&gt;&lt;i&gt;&lt;em&gt;I ran across an interesting quote form the author of markdown&lt;/em&gt;&lt;/i&gt;&lt;/a&gt;&lt;i&gt;&lt;em&gt;:&lt;/em&gt;&lt;/i&gt;&lt;/p&gt;&lt;blockquote&gt;I have no idea why there are now apps that use Markdown as their back end storage format but only show styled text without the Markdown source code visible. Hey World, for example, gets this right: they just do simple WYSIWYG editing where bold is bold, italic is italic, and links look like links and the linked URL is edited in a popup. If you want WYSIWYG, do WYSIWYG. If you want Markdown, show the Markdown. Trust me, it’s meant to be shown.&lt;br&gt;&lt;br&gt;- &lt;a href="https://en.wikipedia.org/wiki/John_Gruber"&gt;John Gruber&lt;/a&gt;&lt;/blockquote&gt;
&lt;/div&gt;
</content>
    <summary type="html">I started this iteration of my blog because I grew dissatisfied with Medium. Like anyone migrating from any one platform to another I requested an export of my blog posts from them and got back a ZIP file containing HTML files.

The first thing that stood out was a lack of any media files - there...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/29</id>
    <published>2023-01-24T14:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/vanilla-rails-view-components-with-partials-41zctBGbN9ka"/>
    <title>Vanilla Rails view components with partials</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;Many projects I work on have some kind of view component that is repeated multiple times in the same view, or is present in multiple different views. These view components can be anything that has a specific styling, JavaScript specific attributes (like Stimulus controllers), rendering logic, and HTML element structure that always has to be the same for the component to render properly - like cards, containers, content boxes, modals, lists, tables, and so on.&lt;br&gt;&lt;br&gt;There are three ways that I have seen people work with view components in Rails - by copy-pasting them around, by using one of the &lt;a href="https://github.com/ViewComponent/view_component"&gt;view&lt;/a&gt; &lt;a href="https://github.com/trailblazer/cells"&gt;component&lt;/a&gt; gems, and partials.&lt;br&gt;&lt;br&gt;Copy-pasting, even when using &lt;a href="https://getbem.com/introduction/"&gt;CSS conventions like BEM&lt;/a&gt;, has the downside of being laborious to update should the component ever change. And I find the gems to be unnecessary since vanilla Rails already has all the features to render and manage view components through &lt;a href="https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials"&gt;partials&lt;/a&gt;.&lt;br&gt;&lt;br&gt;This is an example of three different view components combined together to render a table-like list and a map within a content box with a title using just partials.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyOT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--45f42c4e84bd7c0e0e37049c1429999d9be820d6" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZVU9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d264ba289f6bb195ff1548beb6b68914a181f556/Untitled.png" filename="Untitled.png" filesize="601174" width="2512" height="752" previewable="true" presentation="gallery" caption="Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box."&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box." loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI5LCJwdXIiOiJibG9iX2lkIn19--f6b735658e573b7fceaf7a1a1201cc782f7f7397/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Untitled.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box.
  &lt;/figcaption&gt;
&lt;/figure&gt;Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box.&lt;br&gt;&lt;br&gt;The &lt;b&gt;&lt;strong&gt;component&lt;/strong&gt;&lt;/b&gt; method might seem like there is a lot of magic under the hood, but there really isn’t. I added this method through &lt;a href="https://api.rubyonrails.org/classes/ActionController/Helpers.html"&gt;a view helper&lt;/a&gt; and it does just two things - it calls &lt;b&gt;&lt;strong&gt;render&lt;/strong&gt;&lt;/b&gt; with &lt;b&gt;&lt;strong&gt;"components/#{component_name}"&lt;/strong&gt;&lt;/b&gt; so that I don’t have to write the same incantation all over the place, and I’ll explain the the second purpose later.&lt;br&gt;&lt;br&gt;For now, this is what the helper looks like&lt;p&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ComponentHelper&lt;br&gt;  def component(path, options = {}, &amp;amp;block)&lt;br&gt;    full_path = Pathname.new("components") / path&lt;br&gt;    render(full_path.to_s, options)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;So &lt;b&gt;&lt;strong&gt;component("map", longitude: 15.9765701, latitude: 45.8130054)&lt;/strong&gt;&lt;/b&gt; is the same thing as &lt;b&gt;&lt;strong&gt;render("components/map", longitude: 15.9765701, latitude: 45.8130054)&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Partials allow you to render a snippet of a view wherever you like. For example, instead of having to repeat all the HTML elements and Ruby code that renders two tables:&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/matches/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;h2&amp;gt;Winners&amp;lt;/h2&amp;gt;&lt;br&gt;&amp;lt;table&amp;gt;&lt;br&gt;  &amp;lt;thead&amp;gt;&lt;br&gt;    &amp;lt;tr&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Rank&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Player&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Score&amp;lt;/th&amp;gt;&lt;br&gt;    &amp;lt;/tr&amp;gt;&lt;br&gt;  &amp;lt;/thead&amp;gt;&lt;br&gt;  &amp;lt;tbody&amp;gt;&lt;br&gt;    &amp;lt;%= @match.winners.each do |player| %&amp;gt;&lt;br&gt;      &amp;lt;tr&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;      &amp;lt;/tr&amp;gt;&lt;br&gt;    &amp;lt;% end %&amp;gt;&lt;br&gt;  &amp;lt;/tbody&amp;gt;&lt;br&gt;&amp;lt;/table&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;h2&amp;gt;Losers&amp;lt;/h2&amp;gt;&lt;br&gt;&amp;lt;table&amp;gt;&lt;br&gt;  &amp;lt;thead&amp;gt;&lt;br&gt;    &amp;lt;tr&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Rank&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Player&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Score&amp;lt;/th&amp;gt;&lt;br&gt;    &amp;lt;/tr&amp;gt;&lt;br&gt;  &amp;lt;/thead&amp;gt;&lt;br&gt;  &amp;lt;tbody&amp;gt;&lt;br&gt;    &amp;lt;%= @match.losers.each do |player| %&amp;gt;&lt;br&gt;      &amp;lt;tr&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;        &amp;lt;td&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;      &amp;lt;/tr&amp;gt;&lt;br&gt;    &amp;lt;% end %&amp;gt;&lt;br&gt;  &amp;lt;/tbody&amp;gt;&lt;br&gt;&amp;lt;/table&amp;gt;&lt;/pre&gt;&lt;p&gt;With partials you can just render the partial once for each table and pass it a local with the content it should render:&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/matches/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;h2&amp;gt;Winners&amp;lt;/h2&amp;gt;&lt;br&gt;&amp;lt;!-- we are passing `@match.winners` as the local `players` to the partial --&amp;gt;&lt;br&gt;&amp;lt;%= render "players_table", players: @match.winners %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;h2&amp;gt;Losers&amp;lt;/h2&amp;gt;&lt;br&gt;&amp;lt;%= render "players_table", players: @match.losers %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/matches/_players_table.html.erb --&amp;gt;&lt;br&gt;&amp;lt;table&amp;gt;&lt;br&gt;  &amp;lt;thead&amp;gt;&lt;br&gt;    &amp;lt;tr&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Rank&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Player&amp;lt;/th&amp;gt;&lt;br&gt;      &amp;lt;th&amp;gt;Score&amp;lt;/th&amp;gt;&lt;br&gt;    &amp;lt;/tr&amp;gt;&lt;br&gt;  &amp;lt;/thead&amp;gt;&lt;br&gt;  &amp;lt;tbody&amp;gt;&lt;br&gt;    &amp;lt;!-- the `players` local shows up in this partial as the `players` variable --&amp;gt;&lt;br&gt;    &amp;lt;%= render collection: players, partial: "matches/player" %&amp;gt;&lt;br&gt;    &amp;lt;!-- the above ^ is a shorthand for:&lt;br&gt;    ```&lt;br&gt;      &amp;lt;% players.each do |player| %&amp;gt;&lt;br&gt;        &amp;lt;%= render "matches/player", player: player %&amp;gt;&lt;br&gt;      &amp;lt;% end %&amp;gt;&lt;br&gt;    ```&lt;br&gt;    --&amp;gt;&lt;br&gt;  &amp;lt;/tbody&amp;gt;&lt;br&gt;&amp;lt;/table&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/matches/_player.html.erb --&amp;gt;&lt;br&gt;&amp;lt;tr&amp;gt;&lt;br&gt;  &amp;lt;td&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;  &amp;lt;td&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;  &amp;lt;td&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/td&amp;gt;&lt;br&gt;&amp;lt;/tr&amp;gt;&lt;/pre&gt;&lt;p&gt;Using partials like this works for components that have predetermined content (like tables, maps and lists), but it’s a pain for components that can have anything inside them (like containers, content boxes and modals).&lt;br&gt;&lt;br&gt;You could extract the content of each component into its own partial and pass the the name of the partial you want to render.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/teams/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;%= render "content_box", content_partial: "weekly_metrics", content_options: { team: @team } %&amp;gt;&lt;br&gt;&amp;lt;%= render "content_box", content_partial: "monthly_metrics", content_options: { team: @team } %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/teams/_content_box.html.erb --&amp;gt;&lt;br&gt;&amp;lt;section&amp;gt;&lt;br&gt;  &amp;lt;%= render content_partial, content_options %&amp;gt;&lt;br&gt;&amp;lt;/section&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/teams/_weekly_metrics.html.erb --&amp;gt;&lt;br&gt;&amp;lt;p&amp;gt;Matches won: &amp;lt;%= team.matches.where(created_at: (1.week.ago...)).won.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&amp;lt;p&amp;gt;Matches lost: &amp;lt;%= team.matches.where(created_at: (1.week.ago...)).lost.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&amp;lt;p&amp;gt;Upcoming matches: &amp;lt;%= team.matches.where(created_at: (Time.current...)).count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/teams/_monthly_metrics.html.erb --&amp;gt;&lt;br&gt;&amp;lt;p&amp;gt;Matches won: &amp;lt;%= team.matches.where(created_at: (1.month.ago...)).won.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&amp;lt;p&amp;gt;Matches lost: &amp;lt;%= team.matches.where(created_at: (1.month.ago...)).lost.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;/pre&gt;&lt;p&gt;This can be avoided by passing a block to the partial and yielding to it.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/teams/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;%= render "content_box" do %&amp;gt;&lt;br&gt;  &amp;lt;p&amp;gt;Matches won: &amp;lt;%= @team.matches.where(created_at: (1.week.ago...)).won.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;  &amp;lt;p&amp;gt;Matches lost: &amp;lt;%= @team.matches.where(created_at: (1.week.ago...)).lost.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;  &amp;lt;p&amp;gt;Upcoming matches: &amp;lt;%= @team.matches.where(created_at: (Time.current...)).count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&amp;lt;% end %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;%= render "content_box" do %&amp;gt;&lt;br&gt;  &amp;lt;p&amp;gt;Matches won: &amp;lt;%= @team.matches.where(created_at: (1.month.ago...)).won.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;  &amp;lt;p&amp;gt;Matches lost: &amp;lt;%= @team.matches.where(created_at: (1.month.ago...)).lost.count %&amp;gt;&amp;lt;/p&amp;gt;&lt;br&gt;&amp;lt;% end %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/teams/_content_box.html.erb --&amp;gt;&lt;br&gt;&amp;lt;section&amp;gt;&lt;br&gt;  &amp;lt;%= yield %&amp;gt;&lt;br&gt;  &amp;lt;!-- `yield` will be replaced by whatever is passed in the block --&amp;gt;&lt;br&gt;&amp;lt;/section&amp;gt;&lt;/pre&gt;&lt;p&gt;That’s how &lt;b&gt;&lt;strong&gt;component("content_box", title: "Location") do&lt;/strong&gt;&lt;/b&gt; works.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;% component("content_box", title: "Location") do %&amp;gt;&lt;br&gt;  &amp;lt;h1&amp;gt;Hello World!&amp;lt;/h1&amp;gt;&lt;br&gt;&amp;lt;% end %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- app/views/components/_content_box.html.erb --&amp;gt;&lt;br&gt;&amp;lt;section class="rounded shadow bg-white"&amp;gt;&lt;br&gt;  &amp;lt;!-- `local_assigns` is a hash that contains all the locals passed to a partial --&amp;gt;&lt;br&gt;  &amp;lt;!-- we can check if a local is present and access it's value through it --&amp;gt;&lt;br&gt;  &amp;lt;% if local_assigns.key?(:title) %&amp;gt;&lt;br&gt;    &amp;lt;h4 class="text-gray-900 text-lg"&amp;gt;&lt;br&gt;      &amp;lt;%= local_assigns[:title] %&amp;gt;&lt;br&gt;      &amp;lt;!-- I could have used `title` instead of `local_assigns[:title]` --&amp;gt;&lt;br&gt;    &amp;lt;/h4&amp;gt;&lt;br&gt;  &amp;lt;% end %&amp;gt;&lt;br&gt;  &amp;lt;div&amp;gt;&lt;br&gt;    &amp;lt;%= yield %&amp;gt;&lt;br&gt;  &amp;lt;/div&amp;gt;&lt;br&gt;&amp;lt;/section&amp;gt;&lt;/pre&gt;&lt;p&gt;This brings me back to the second purpose of the &lt;b&gt;&lt;strong&gt;component&lt;/strong&gt;&lt;/b&gt; method - fixing localization.&lt;br&gt;&lt;br&gt;If I would use full localization keys for everything then there wouldn’t be a problem with just using &lt;b&gt;&lt;strong&gt;render&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;% render("components/content_box", title: t("properties.show.location")) do %&amp;gt;&lt;br&gt;  &amp;lt;h1&amp;gt;&amp;lt;%= t("properties.show.hello_world") %&amp;gt;&amp;lt;/h1&amp;gt;&lt;br&gt;&amp;lt;% end %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- config/locales/en.yml --&amp;gt;&lt;br&gt;en:&lt;br&gt;  properties:&lt;br&gt;    show:&lt;br&gt;      location: "Location"&lt;br&gt;      hello_world: "Hello World!"&lt;br&gt;&lt;br&gt;&amp;lt;!-- rendered HTML --&amp;gt;&lt;br&gt;&amp;lt;section class="rounded shadow bg-white"&amp;gt;&lt;br&gt;  &amp;lt;h4 class="text-gray-900 text-lg"&amp;gt;Location&amp;lt;/h4&amp;gt;&lt;br&gt;  &amp;lt;div&amp;gt;&lt;br&gt;    &amp;lt;h1&amp;gt;Hello World!&amp;lt;/h1&amp;gt;&lt;br&gt;  &amp;lt;/div&amp;gt;&lt;br&gt;&amp;lt;/section&amp;gt;&lt;/pre&gt;&lt;p&gt;But I’m lazy and like to use relative localization keys everywhere, which causes a problem.&lt;/p&gt;&lt;pre data-language="html"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;br&gt;&amp;lt;% render("components/content_box", title: t(".location")) do %&amp;gt;&lt;br&gt;  &amp;lt;h1&amp;gt;&amp;lt;%= t(".hello_world") %&amp;gt;&amp;lt;/h1&amp;gt;&lt;br&gt;&amp;lt;% end %&amp;gt;&lt;br&gt;&lt;br&gt;&amp;lt;!-- config/locales/en.yml --&amp;gt;&lt;br&gt;en:&lt;br&gt;  properties:&lt;br&gt;    show:&lt;br&gt;      location: "Location"&lt;br&gt;      hello_world: "Hello World!"&lt;br&gt;&lt;br&gt;&amp;lt;!-- rendered HTML --&amp;gt;&lt;br&gt;&amp;lt;section class="rounded shadow bg-white"&amp;gt;&lt;br&gt;  &amp;lt;h4 class="text-gray-900 text-lg"&amp;gt;Location&amp;lt;/h4&amp;gt;&lt;br&gt;  &amp;lt;div&amp;gt;&lt;br&gt;    &amp;lt;h1&amp;gt;&lt;br&gt;      &amp;lt;!-- This is what Rails render when a translation is missing --&amp;gt;&lt;br&gt;      &amp;lt;!-- Notice that it looked for the translation in `en.components.content_box.hello_world` --&amp;gt;&lt;br&gt;      &amp;lt;!-- instead of in `en.properties.show.hello_world` --&amp;gt;&lt;br&gt;      &amp;lt;span class="translation_missing" title="translation missing: en.components.content_box.hello_world"&amp;gt;&lt;br&gt;        Hello World&lt;br&gt;      &amp;lt;/span&amp;gt;&lt;br&gt;    &amp;lt;/h1&amp;gt;&lt;br&gt;  &amp;lt;/div&amp;gt;&lt;br&gt;&amp;lt;/section&amp;gt;&lt;/pre&gt;&lt;p&gt;With relative localization keys, Rails prefixed the key with the path of the component instead of the parent.&lt;br&gt;&lt;br&gt;When a view yields the passed block is rendered in the context of that view, not in the context of the view where the block was created. Since the partial yields the relative localization key prefix is &lt;b&gt;&lt;strong&gt;components.content_box&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;To solve this I can either use full localization keys within components, which is annoying; or I can add the translation to the component’s localization, which means that I would have to think about possible key collisions when using relative localization keys within components.&lt;br&gt;&lt;br&gt;Thankfully there is a way to capture a part of a view within the context of the current view and store it as a variable. With the &lt;b&gt;&lt;strong&gt;capture&lt;/strong&gt;&lt;/b&gt; &lt;a href="https://api.rubyonrails.org/classes/ActionView/Helpers/CaptureHelper.html#method-i-capture"&gt;method&lt;/a&gt; I can yield the block passed to the component in the context of the current view, store the result to a variable, and pass that variable in a block to &lt;b&gt;&lt;strong&gt;render&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Here is the full &lt;b&gt;&lt;strong&gt;ComponentHelper&lt;/strong&gt;&lt;/b&gt; code with the relative locale keys fix.&lt;/p&gt;&lt;pre data-language="ruby"&gt;module ComponentHelper&lt;br&gt;  def component(path, locals = {}, &amp;amp;block)&lt;br&gt;    full_path = Pathname.new("components") / path&lt;br&gt;&lt;br&gt;    if block&lt;br&gt;      # Render the passed block within the current context and&lt;br&gt;      # store it in the `content` variable&lt;br&gt;      content = capture do&lt;br&gt;        block.call&lt;br&gt;      end&lt;br&gt;&lt;br&gt;      # Call render but pass it a new block that yields just&lt;br&gt;      # the contents of the `content` variable&lt;br&gt;      render(full_path.to_s, locals) { content }&lt;br&gt;    else&lt;br&gt;      render(full_path.to_s, locals)&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;And that’s all there is to it - &lt;b&gt;&lt;strong&gt;render&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;local_assigns&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;yield&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;capture&lt;/strong&gt;&lt;/b&gt;.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Many projects I work on have some kind of view component that is repeated multiple times in the same view, or is present in multiple different views. These view components can be anything that has a specific styling, JavaScript specific attributes (like Stimulus controllers), rendering logic, and...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/28</id>
    <published>2023-01-21T16:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/covid-hangover-5En5QhX2ecem"/>
    <title>COVID hangover</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;With a lot of tech companies laying people off in the last month I got to see how insane the firing practices in the US are and how greed rules supreme in the tech industry. But this is only a harbinger of what’s to come.&lt;br&gt;&lt;br&gt;Through friends and from what I’ve seen first hand, the standard procedure to fire someone in the US is to invite them to a meeting, tell them that they have been let go, and immediately suspend all their company accounts. &lt;a href="https://blog.google/inside-google/message-ceo/january-update/"&gt;Or email them to tell them they have been let go (effective immediately)&lt;/a&gt;, or worse yet &lt;a href="https://www.npr.org/2022/11/17/1137265843/elon-musk-fires-employee-by-tweet"&gt;tell them they have been let go via a tweet&lt;/a&gt;.&lt;br&gt;&lt;br&gt;There is zero humanity in laying people off this way. These people dedicated months or years of their lives building these companies and then one morning they get kicked out the door &lt;a href="https://www.forbes.com/advisor/personal-finance/what-is-the-warn-act/"&gt;with a 60 day severance package - which is the legal minimum&lt;/a&gt; - and aren’t even given the decency to say good bye to their former coworkers.&lt;br&gt;&lt;br&gt;These layoffs weren’t triggered by financial losses but by speculations of a looming recession. Companies are afraid that money won’t be cheap and profits will be low in the coming years and they are cutting their losses while they still can in an effort to keep investors and shareholders happy. In other words, they are cutting headcount to cut expenses in an effort to prop up profits. Which is extremely greedy after a year of record profits and shows no regard for the humans that made this growth possible in the first place.&lt;br&gt;&lt;br&gt;I believe that these layoffs are not an indication of the tech bubble bursting, but are just the hangover after the COVID lock-downs when tech seemed like the only unaffected industry. This caused every company in the industry to over-hire and expand in the pursue of even higher profits. But expecting 20-30% returns year-after-year and infinite growth is unrealistic - that can only be a bubble. After a couple of good years a bad one is bound to happen. And if there is going to be a recession then profits should be low - don’t turn a bad day for you into a tragedy for others. Sit it out and don’t be greedy.&lt;br&gt;&lt;br&gt;If the industry weathers this recession relatively unscathed people will see tech as an even better investment vehicle which will in turn lead to more investment, more hype, and the bubble will inflate even more. This seems to be the outcome that most big companies are banking on since even after the layoffs most of them still have more employees than they had two years ago, indicating that they want to use the extra manpower to expand.&lt;br&gt;&lt;br&gt;I don’t know when or how this bubble will burst, but it will be one hell of a pop when it does.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">With a lot of tech companies laying people off in the last month I got to see how insane the firing practices in the US are and how greed rules supreme in the tech industry. But this is only a harbinger of what’s to come.

Through friends and from what I’ve seen first hand, the standard procedure...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/27</id>
    <published>2022-12-04T20:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/elden-ring-9aC2HBpWCnoG"/>
    <title>Elden Ring</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;This week I finished Elden Ring, and the moment I put the controller down I felt a void as if a good friend moved to another country and I wouldn’t see them for a long time. This was the best game I have played in years.&lt;br&gt;&lt;br&gt;But why? On the surface it seems just like any other open-world ARPG, yet Elden Ring is bigger than the sum of its parts. This game is a master class in the &lt;a href="https://en.wikipedia.org/wiki/Omakase"&gt;&lt;em&gt;omakase&lt;/em&gt;&lt;/a&gt; design approach and that’s what makes it so amazing.&lt;br&gt;&lt;br&gt;The game makes two key decisions for you - the difficulty level and the story exposition.&lt;br&gt;&lt;br&gt;There is no way to change the difficulty in this game. You find a boss difficult? Tough luck, you will have to get better. Everybody has to face the same challenging fights, enemies, quest and puzzles. This was the first &lt;a href="https://en.wikipedia.org/wiki/Soulslike"&gt;Souls game by From Software&lt;/a&gt; I have played and this design decision felt hostile to me, sometimes I just want to relax with a game and enjoy the story.&lt;br&gt;&lt;br&gt;But after I beat the first boss I felt a sense of accomplishment and then it clicked - the difficulty is what makes each win feel so rewarding. The combat is punishing, but it’s also fair. It’s like a dance to which you have to figure out the steps or get trampled (or get so overpowered that you can steamroll through it). There are a few bullshit attacks that are hard to dodge, but everything else is clearly telegraphed - if you make a mistake you know it was on you. The dance-like nature of the combat is also why getting attacked by more than one enemy at a time is infinitely more difficult - you have to keep track of two dance partners.&lt;br&gt;&lt;br&gt;But the thing that makes the combat of Elden Ring so great is its open world. When I got stuck, bored or annoyed I could always just go somewhere else and have fun. There is no better example of this than the first mini-boss. I exited the tutorial level to find myself in a lush grassy field with a giant golden knight in the distance. As the naive spring chicken that I was, I walked up to the shiny knight only to see a large “YOU DIED” message a second later. Then I tried to fight the knight, and no matter what I did the knight would demolish me before I got him to half-health. At that moment I understood that the game was trying to teach me that if I’m not having fun that I should look elsewhere. Nothing was forcing me to fight that kings, I could just go around him and come back later.&lt;br&gt;&lt;br&gt;There are a few traps in the game that take away your ability to travel and only then does it become obvious how crucial the open-world is to the combat experience of this game. I got eaten by the Abductor Virgin (Iron Maiden) in Raya Lucaria Academy and got teleported to a lava lake in Volcano Manor, under-geared and under-leveled, only to spend and hour in agony before I gave up and looked up how to cheese an escape.&lt;br&gt;&lt;br&gt;The game does use its combat difficulty as a level-gate to steer you round the world. But because of this the world always feels challenging and keeps you on your feet. I was decimating archers and knight, fighting my way to a demigod boss only to emerge in another region of the world after the fight where a stray dog and a guy with a torch could mess my day up.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyOD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--919e17d86a58ad879209ae3e688f8dae3ac0fccf" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZVE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--8d9446cc9bd29deaf11e4133430aacc907a3c0d5/meme.png" filename="meme.png" filesize="720668" width="1155" height="963" previewable="true" caption="Strong Doge meme. The strong one tanks hits by demigods, the weaker one is bullied by two dogs and a guy with a torch"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Strong Doge meme. The strong one tanks hits by demigods, the weaker one is bullied by two dogs and a guy with a torch" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI4LCJwdXIiOiJibG9iX2lkIn19--5aa96f25b1649ec0e274e3b765f6801acbe5061d/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/meme.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Strong Doge meme. The strong one tanks hits by demigods, the weaker one is bullied by two dogs and a guy with a torch
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Exploration is not only important to get better gear and level up, but also for the story. The game doesn’t hold your hand when it comes to the main story, it expects that you will go out of your way to figure it out. This also seemed hostile at first.&lt;br&gt;&lt;br&gt;There are a few expositions during key quest moments and demigod fights, but that’s not enough to figure out what is going on. The story through the cutscenes alone is a jumbled mess - you want to become Elden Lord, because that’s what one does, and there are demigods you have to kill to get shards of the Elden Ring, which shattered in the past, and only with those shards will you be able to become Elden Lord. What is this? Pokemon fan fiction? How many gym trainers do I have to beat to become a Pokemon master?&lt;br&gt;&lt;br&gt;The details of the story are scattered in side-quests and item descriptions which can only be found by exploring. I was half-way through the game when I discovered that &lt;a href="https://old.reddit.com/r/Eldenring/comments/vgvy7y/best_item_description/"&gt;item descriptions contain the lore of the world&lt;/a&gt;. The other thing I discovered half-way through the game is that finishing side quests unlocks alternate endings. This made exploration more enjoyable and rewarding compared to other open-world ARPGs, I felt like Indiana Jones recovering parts of the Ark of the Covenant.&lt;br&gt;&lt;br&gt;These two decisions - a single difficulty level for all and a vague story - complement each other in a way that makes the world feel like a sandbox where I can go explore and adventure. It doesn’t get old and it’s not a gimmick like in other ARPGs. It’s so good that this is the first game where, after beating it, I immediately started a second play-through to experience what I have missed.&lt;br&gt;&lt;br&gt;This is only possible because of omakase. The team at From Software made a tailored experience, an experience that takes existing elements of ARPGs, sacrifices some quality-of-life features, but arranges what’s left in such a way that they become better than in all other ARPGs.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">This week I finished Elden Ring, and the moment I put the controller down I felt a void as if a good friend moved to another country and I wouldn’t see them for a long time. This was the best game I have played in years.

But why? On the surface it seems just like any other open-world ARPG, yet...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/26</id>
    <published>2022-11-27T21:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/cutting-through-the-noise-zadsDfjGrxyd"/>
    <title>Cutting through the noise</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;I use writing as a tool to organize my thoughts, but lately I have started to question if that’s the right order to do things.&lt;br&gt;&lt;br&gt;Writing is thinking. Text is just someone’s thoughts on paper. It’s obvious that thinking has to come first, but that’s not always the case.&lt;br&gt;&lt;br&gt;When I sit down to write something I often just have a notion about what I want to write, and only through the process of writing do I form a full thought. This process can take days to produce an article. But as I started writing more consistently, the time required to write an article went down to a few hours.&lt;br&gt;&lt;br&gt;This week I heard that DHH writes articles in 15min and I was taken aback. I enjoy his articles, he has a way of explaining things in a clear and forward way - something I am trying to improve, and which I envy.&lt;br&gt;&lt;br&gt;The only way I see that this is possible is by sitting down with a fully formed thought before writing a single word. He’s able to cut through the noise and distill a thought without the aid of writing.&lt;br&gt;&lt;br&gt;Have I been putting the carriage before the horse all this time?&lt;br&gt;&lt;br&gt;Well, yes and no. To write that way I have to trust my self more - something I see I have been avoiding by using writing as a crutch to verify my thoughts, double and triple check them. I’m just avoiding criticism, but that means that my written thoughts are a watered down vanilla version of the real thing. I have to become bolder in expressing my opinion. Then again, there is nothing wrong with using writing as a tool from time to time.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">I use writing as a tool to organize my thoughts, but lately I have started to question if that’s the right order to do things.

Writing is thinking. Text is just someone’s thoughts on paper. It’s obvious that thinking has to come first, but that’s not always the case.

When I sit down to write...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/25</id>
    <published>2022-11-20T21:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/email-Hp5pkUZLCrw4"/>
    <title>Email</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;For me, instant messaging is overwhelming. Keeping up with &lt;a href="https://slack.com/"&gt;Slack&lt;/a&gt; or &lt;a href="https://discord.com/"&gt;Discord&lt;/a&gt; messages often feels like drinking from a fire hose of information. And some features of these apps bring out the worst in people. I wish these apps were more like email, because email got a lot of things right.&lt;br&gt;&lt;br&gt;My biggest complaint with instant messaging is how distracting it is. Notifications pop up to grab your attention for every message, most of which aren’t intended for me.&lt;br&gt;&lt;br&gt;This is a necessary byproduct of organizing communication into channels. On the surface this is a great idea to focus the discussion on a topic, but in practice it heavily hinges on the netiquette of the participants.&lt;br&gt;&lt;br&gt;Like every group chat ever, a channel will spawn off-topic conversations that will ping everyone in the channel.&lt;br&gt;&lt;br&gt;Eventually, somebody will start discussing sandwiches in a channel for handling outages. Or someone will start discussing a baseball game in the general channel. Both of these are real examples.&lt;br&gt;&lt;br&gt;I feel like the only real option in such cases is to mute or leave the channel, but that’s not always an option. I can’t leave the outage channel, and I can’t mute it. So what now?&lt;br&gt;&lt;br&gt;Topics (or sub-chats) are a great way to combat this. But again, they hinge on the netiquette of the participants. If nobody uses threads they are useless. If too many are created, they become hard to follow.&lt;br&gt;&lt;br&gt;Then there is the instant part of instant messaging that entices some people to write down their stream of consciousness for others to interpret, instead of taking the time to organize their thoughts into a sentence.&lt;br&gt;&lt;br&gt;You&lt;br&gt;&lt;br&gt;Know&lt;br&gt;&lt;br&gt;The&lt;br&gt;&lt;br&gt;Type&lt;br&gt;&lt;br&gt;Of&lt;br&gt;&lt;br&gt;People&lt;br&gt;&lt;br&gt;Who&lt;br&gt;&lt;br&gt;Send&lt;br&gt;&lt;br&gt;Messages&lt;br&gt;&lt;br&gt;Like&lt;br&gt;&lt;br&gt;This&lt;br&gt;&lt;br&gt;Just&lt;br&gt;&lt;br&gt;So&lt;br&gt;&lt;br&gt;They&lt;br&gt;&lt;br&gt;Can&lt;br&gt;&lt;br&gt;Write&lt;br&gt;&lt;br&gt;Something&lt;br&gt;&lt;br&gt;Instantly&lt;br&gt;&lt;br&gt;Then there is the other kind of instant in instant messaging - the instant response - to which some people feel entitlement.&lt;br&gt;&lt;br&gt;I’ve had people ping me with “I saw you replied in the channel 15 min ago, but not to me”. Or people ping me with a question and 3 min later sending another one saying that my lack of response will be considered a no. And I had people ping me with “Are you there?” every 5 min.&lt;br&gt;&lt;br&gt;I don’t know why instant messaging brings out the worst in people. But I know that a lot of people choose to always appear offline to avoid these problems.&lt;br&gt;&lt;br&gt;Which brings me to email. It never had an online status to begin with, every conversation is a thread, there is no expectation of an instant response, and people usually write full sentence responses.&lt;br&gt;&lt;br&gt;Some of these “features” were technological or practical limitations of the time, and some evolved out of the way people used email.&lt;br&gt;&lt;br&gt;But email evolved into a great, distraction free, way to communicate by using these friction points to its advantage.&lt;br&gt;&lt;br&gt;It has its problems, like not having a mechanism to leave a thread, or having some way to track branching threads. But apps like &lt;a href="https://www.hey.com/"&gt;Hey&lt;/a&gt; have shown that it’s possible to resolve these problems. And showed that the federated design of email allows anyone to innovate and improve &lt;a href="https://en.wikipedia.org/wiki/History_of_email"&gt;this 50-year-old protocol&lt;/a&gt;.&lt;br&gt;&lt;br&gt;There are so many wonderful solutions on top of email out there. Apps like &lt;a href="https://mailbrew.com/"&gt;Mailbrew&lt;/a&gt; - which combines RSS, Reddit, Twitter even weather forecasts into a single daily email.&lt;br&gt;&lt;br&gt;Anyone can send you an email. Which is a blessing and a curse thanks to spammers. But I’ve had wonderful conversations over the years that have cold-mailed me after stumbling upon one of my projects or articles. And, again, apps like Hey add ways to screen incoming emails before they end up in your inbox.&lt;br&gt;&lt;br&gt;I recently tried &lt;a href="https://twistapp.com/"&gt;Twist&lt;/a&gt; and found that by mimicking email it became a better instant messenger - if you can call it that. It only shows that there is still a lot of room left to improve instant messengers. At least the work-focused ones like Slack.&lt;br&gt;&lt;br&gt;Email is still the best communication method for me.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">For me, instant messaging is overwhelming. Keeping up with Slack or Discord messages often feels like drinking from a fire hose of information. And some features of these apps bring out the worst in people. I wish these apps were more like email, because email got a lot of things right.

My...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/24</id>
    <published>2022-11-13T12:00:00Z</published>
    <updated>2026-03-09T08:42:12Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/tech-debt-for-non-techies-4rQhGJdxiMuY"/>
    <title>Tech debt for non-techies</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Tech dept doesn’t occur by accident. You go into it intentionally - it is a loan like any other, but instead of loaning money you loan time.&lt;br&gt;&lt;br&gt;When you want to “save time” by implementing a hack (something that goes against the grain of the software’s architecture) you are making an intentional bad decision - you don’t have the time to come up with a better architecture so you defer the problem to future you (or, with any luck, someone else). Essentially, you are borrowing time you don’t have now from the future.&lt;br&gt;&lt;br&gt;Like with a regular loan, you have to pay that time back. But without a bank to remind you that your installment is due, it’s easy to forget about this loan. Forgetting the loan sometimes works out, and other times it doesn’t.&lt;br&gt;&lt;br&gt;Tech debt won’t cause you problems if everybody on the team understands what it is and what its trade-offs are - how time is traded for functionality, and how to manage this kind of loan. Technical team members are painfully aware of tech debt because they are the ones that have to manage it and pay it back if necessary, but non-technical team members are usually oblivious to it.&lt;br&gt;&lt;br&gt;And I don’t blame them, it’s a concept that can be hard to fully grasp. Sure, most people will think they understand it from reading the loan analogy from before. But they won’t be able to see it even when it’s happening in front of their eyes.&lt;br&gt;&lt;br&gt;But I found a way to illustrate the problems and processes that cause tech debt through Factorio.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM0Nj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--451961ea2b52d7589fcf75c4cb7a05ce86664711" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbG9CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b170644cf3140b1e8a185b892b1067536315ae99/factorio_blogpost_intro-2400.webm" filename="factorio_blogpost_intro-2400.webm" filesize="23668808" width="1280.0" height="720.0" previewable="true" caption="Montage that demonstrates the basics of Factorio - gathering resources, fighting aliens, building factories and finally launching the rocket"&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ2LCJwdXIiOiJibG9iX2lkIn19--64164352f36192bc184ef73be19e321d127ee684/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/factorio_blogpost_intro-2400.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ2LCJwdXIiOiJibG9iX2lkIn19--64164352f36192bc184ef73be19e321d127ee684/factorio_blogpost_intro-2400.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Montage that demonstrates the basics of Factorio - gathering resources, fighting aliens, building factories and finally launching the rocket
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Factorio is a game in which you play as an engineer stranded on an alien planet. Surrounded by nothing but trees, rocks, minerals, and a hoard of aggressive aliens. Your goal is to make shelter, fight or co-exist with the aliens, and get off the planet by building and launching a rocket.&lt;br&gt;&lt;br&gt;The problem is obvious - how do you make a rocket and rocket fuel from sticks and stones? The answer is simple - step by step.&lt;br&gt;&lt;br&gt;In Factorio, more complex devices can be made out of simpler ones or out of raw materials like rock, iron, copper, coal, oil and uranium.&lt;br&gt;&lt;br&gt;In the beginning you can only gather and build things. So you do exactly that. You gather rocks to make a furnace, then you gather Iron ore to smelt it into plates.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM0Nz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a8f557d2a1c93e40ff62f1e8bf142797849062a1" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbHNCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--73235682a8989e7c67ef5c6657da891ee9b93c8d/factorio_blogpost_crafting.webm" filename="factorio_blogpost_crafting.webm" filesize="15056051" width="1280.0" height="720.0" previewable="true" caption="Gathering of 5 stone to craft a furnace, and then smelting an iron plate in it by feeding it iron ore and coal"&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ3LCJwdXIiOiJibG9iX2lkIn19--2bd5f8a1045737c344a3b4a4736bddc30f597802/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/factorio_blogpost_crafting.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ3LCJwdXIiOiJibG9iX2lkIn19--2bd5f8a1045737c344a3b4a4736bddc30f597802/factorio_blogpost_crafting.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Gathering of 5 stone to craft a furnace, and then smelting an iron plate in it by feeding it iron ore and coal
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Iron plates enable you to build drills and conveyor belts. Now you don’t have to dig and walk so much anymore.&lt;br&gt;&lt;br&gt;Soon you can build robotic arms that can pick up things from the conveyor belts and feed them to other machines. Now you can automate whole processes like smelting iron and copper.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM0OD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--9843ae9ce3bc997b4d317487935fef871693e525" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbHdCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--cb77a367b5e5e482755a6ed9ee7428f1a873ff1d/factorio_blogpost_automation.webm" filename="factorio_blogpost_automation.webm" filesize="3974033" width="1280.0" height="720.0" previewable="true" caption="A basic automated coal and iron mining setup"&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ4LCJwdXIiOiJibG9iX2lkIn19--fd4c44c325fa1af218e1d27ce7e0b01c088f15c9/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/factorio_blogpost_automation.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ4LCJwdXIiOiJibG9iX2lkIn19--fd4c44c325fa1af218e1d27ce7e0b01c088f15c9/factorio_blogpost_automation.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A basic automated coal and iron mining setup
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;And that is the basic loop of the game - gather, build, automate. But it gets complicated. Over time you will build a rocket building factory. There will be machines making iron, turning it into electronics, building other machines that are parts of other machines that are part of your rocket, and all of them will be connected with conveyor belts and robotic arms.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM0OT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--399eebd031dbf2c7c1902555cab7267717365400" content-type="video/webm" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbDBCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--57882c0c3d4f4908cee5d00c7398999a4bf088e9/untitled.webm" filename="untitled.webm" filesize="6250547" width="1280.0" height="720.0" previewable="true" caption="Running through one part of a bigger factory"&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ5LCJwdXIiOiJibG9iX2lkIn19--8bac7e9fab7e3af2477efc87869a6e4aeb271106/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/untitled.webm" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzQ5LCJwdXIiOiJibG9iX2lkIn19--8bac7e9fab7e3af2477efc87869a6e4aeb271106/untitled.webm"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Running through one part of a bigger factory
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Building such a factory is no easy task if you can’t manage tech debt. And you will run into tech debt pretty quickly.&lt;br&gt;&lt;br&gt;Your first encounter with tech debt will be through conveyor belts. In the beginning you will place parts of your factory wherever they are easiest to fit. Probably close to the resources required to build the parts&lt;br&gt;&lt;br&gt;You will build conveyors close to where you smelt iron. That’s only logical since conveyor belts are expensive and you just spent a lot of resources on an assembler to make the gears and conveyors.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM1MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c3511b31a909bb020e78aa5f49b0b80abef439db" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbDRCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c41eb8aedbbef55646255474f6be1a9ecf00c28c/untitled.png" filename="untitled.png" filesize="1112404" width="893" height="489" previewable="true" caption="A furnace and two assemblers connected with conveyors, building new conveyors"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A furnace and two assemblers connected with conveyors, building new conveyors" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzUwLCJwdXIiOiJibG9iX2lkIn19--c757a52a5b793e08fcad08ab3191e91f7324e40f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/untitled.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A furnace and two assemblers connected with conveyors, building new conveyors
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This kind of thinking and behavior is known as “greedy behavior” in software. Greedy behavior is usually short-sighted, highly rewarding short-term, but it can be much worse than other approaches long-term.&lt;br&gt;&lt;br&gt;And that is exactly what will happen here. If you now want to create an assembler that builds robotic arms, you will run into an issue - the gears that you need to make them are in a box right between the two assemblers and are fully surrounded by conveyor belts. There is no space left to add a robotic arm to extract them from the box and put them on another conveyor.&lt;br&gt;&lt;br&gt;Now you have two choices - tear everything down and rebuild it better, or squeeze in a few conveyors.&lt;br&gt;&lt;br&gt;Rebuilding is the better choice now that you know that you want to automate the assembly of robotic arms. But some people will choose to squeeze in conveyors because of the sunk cost fallacy - they can’t let go of the time and effort they have already invested - or because they don’t have the time to rebuild the factory - in which case they are entering tech debt by intentionally making a bad decision to save some time now.&lt;br&gt;&lt;br&gt;Over time, squeezing in more and more conveyor belts will result in conveyor belt spaghetti. The factory will get so densely covered with conveyor belts that it will become difficult to keep track of which resources are going where. And even worse, it will become hard to add new conveyors and grow the factory - you will have to duplicate parts of your factory to be able to cart the resources to another part of the factory.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM1MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eb1b528004d83d0d2c362124f6b69b21ff3ddeea" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbDhCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--91e9d038cd01b9a7d8b9d3449a82b7b83761b1e7/untitled_1.png" filename="untitled_1.png" filesize="1173119" width="699" height="644" previewable="true" caption="What it looks like when you squeeze in conveyors and duplicate parts of the factory. There are two assemblers making gears, and two furnaces making iron plates. And everything is covered in conveyor belts."&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="What it looks like when you squeeze in conveyors and duplicate parts of the factory. There are two assemblers making gears, and two furnaces making iron plates. And everything is covered in conveyor belts." loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzUxLCJwdXIiOiJibG9iX2lkIn19--26aa8e783de6e1febd2c44b853b6eec7af472902/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/untitled_1.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      What it looks like when you squeeze in conveyors and duplicate parts of the factory. There are two assemblers making gears, and two furnaces making iron plates. And everything is covered in conveyor belts.
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Short-term, squeezing in conveyors will solve your problem. Long-term, it will cause you bigger problems. But there is a third solution - squeeze in the conveyor belts today, and reorganize the factory tomorrow. That is, enter tech debt today and pay it tomorrow.&lt;br&gt;&lt;br&gt;To long term players this is obvious. They would build the factory with expansion in mind from the start. But to new players this isn’t obvious at all.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzM1Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c0037115efbcbe11fad7f6c4dfa7ef24fd5535a2" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbUFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0ed6fec3009c920a9e775cdfc86c62c3fb04e72c/untitled_2.png" filename="untitled_2.png" filesize="2465820" width="985" height="1041" previewable="true" caption="The same factory as before, but built with expansion in mind. Production of raw resources and parts is centralized and then the resources are carted around the factory on conveyor belts. There is plenty room to add more machines or expand existing ones."&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The same factory as before, but built with expansion in mind. Production of raw resources and parts is centralized and then the resources are carted around the factory on conveyor belts. There is plenty room to add more machines or expand existing ones." loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzUyLCJwdXIiOiJibG9iX2lkIn19--8422039c8613ab332391147ab82391a9386d3ffe/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/untitled_2.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The same factory as before, but built with expansion in mind. Production of raw resources and parts is centralized and then the resources are carted around the factory on conveyor belts. There is plenty room to add more machines or expand existing ones.
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;That’s why developers complain and moan about hacks and tech debt, while managers usually struggle to understand the problem. Developers know that, eventually, the code they write today will become the foundation of a new feature tomorrow and they want to prepare for it. While managers are concerned with delivering something today.&lt;br&gt;&lt;br&gt;That is also why tech debt rarely gets addressed in some organizations. When a manager - the person deciding what the developer or designer will build - has to choose between working on a feature or maintenance they will always choose the feature. It is a feature today, they understand that. They don’t understand that maintenance will be a feature tomorrow.&lt;br&gt;&lt;br&gt;In their eyes one is fact, the other is a hypothetical. While in the builder’s eyes both are fact.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Tech dept doesn’t occur by accident. You go into it intentionally - it is a loan like any other, but instead of loaning money you loan time.

When you want to “save time” by implementing a hack (something that goes against the grain of the software’s architecture) you are making an intentional...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/23</id>
    <published>2022-11-04T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/fight-perfection-roooUsSEfduw"/>
    <title>Fight perfection</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Seeking perfection is a fool’s errand.&lt;br&gt;&lt;br&gt;Perfection is a fading spark that can only be achieved for oneself - never when working with others - and as soon as it’s gone the search for the new perfect begins.&lt;br&gt;&lt;br&gt;By constantly searching for perfection you end up living in the fantastic future - in the “what could be” - instead of living in the here and now - in reality. It’s an escape from facing what you should do now.&lt;br&gt;&lt;br&gt;Perfection is the siren song that will steer you into a vortex.&lt;br&gt;&lt;br&gt;So fight it, seek reality, seek better, seek good enough.&lt;br&gt;&lt;br&gt;In my childhood I was encouraged to seek perfection - to get straight A grades, to write the best assignments, to know every subject matter inside out at any moment - and that thought me that one can only achieve perfection for oneself.&lt;br&gt;&lt;br&gt;When I wrote an assignment that I though was perfect - one that I was happy with in every regard - most often I got a B for it. Why? Because it wasn’t perfect for my teacher. Even when it was grammatically perfect, my teacher thought that I could improve flow, or phrasing, or something else. This theme continued in high school, college and at work. Every time I was satisfied - though something was perfect - at least one other person didn’t. There was always something to add, adjust or remove to transform my perfect into their perfect. And sometimes I came to adopt their perfect as my perfect, but sometimes I didn’t.&lt;br&gt;&lt;br&gt;This taught me that perfection is in the eye of the beholder. Every person will have a different opinion about what is perfect. Sometimes people can agree that something is perfect, other times they can’t. Even when a collective perfect is achieved it doesn’t last because every new person that comes along will have a new notion about what could be improved.&lt;br&gt;&lt;br&gt;Searching for perfection among many people can be an impossible task. So don’t do it. Fight this notion. Don’t seek a consensus about what is perfect, do what is good enough for everybody, do that which is better than what you have now.&lt;br&gt;&lt;br&gt;Later on, when I started working, I took part in meetings where we discussed that the perfect solution to a project would be.&lt;br&gt;&lt;br&gt;This usually resulted in some sort of a shared notion of a perfect solution that was presented to the client, but no clear way how to get from where we are now to that perfect solution.&lt;br&gt;&lt;br&gt;Since we didn’t want to inconvenience or disappoint the client we clang to that notion of perfect, this lead to many inconveniences and disappointments for us and the client.&lt;br&gt;&lt;br&gt;Because we had such a rigid goal we never reconsidered if what we were doing made sense. So every now and then we ran into an insurmountable problem or built out nonsensical features that would eventually make sense after something else was built. We were going straight to perfect, regardless of what it took.&lt;br&gt;&lt;br&gt;By thinking about the future we forgot about the road that we have to follow to get there. So don’t focus on the future where everything is perfect, focus on reality, focus on the here and now.&lt;br&gt;&lt;br&gt;We could have presented the perfect solution as an eventual goal that may or may not come true, and commit to build out a project that brings us closer to it, and then commit to another project, and another, and another.&lt;br&gt;&lt;br&gt;That way there isn’t a commitment to a grand goal, so both we and the client are free to change course at any time to avoid problems as they arise. We might not end up at perfect that way, but we would certainly get close to it. And maybe the definition of perfect would change along the way, to one which we could reach.&lt;br&gt;&lt;br&gt;The worst kinds of projects at work were ones that never left the drawing board. On these kinds of projects the client was so obsessed about figuring out the perfect solution that they ran out of money before the product was even close to being done.&lt;br&gt;&lt;br&gt;They spent weeks in meetings seeking the perfect solution, and always came up with something better than what they had before - they found a new perfect and moved the goal post there. That is the nature of being human - we always find a way to make things better than they are now, we can always dream bigger.&lt;br&gt;&lt;br&gt;But you won’t ever make anything better if all you ever do is think about how you can make something better. Don’t obsess about doing something perfectly, just do it better, or good enough.&lt;br&gt;&lt;br&gt;Seeking perfection can give you a vision of the future, but remember that it’s only a vision. If you follow it blindly it can lead you down a twisty and thorny road to nowhere. Use it as a compass to make things better than they are, or else fight its siren song tooth and nail for it can ruin everything it touches.&lt;br&gt;&lt;br&gt;P.S. This topic has been on my mind for a long time. I tried to write it up a couple of times before but always fell short of the goal I set for my self - I didn’t notice that I was chasing perfection. And that’s something that I have struggled with as an adult. It’s only a few years back that I noticed how corrosive this chase for perfection can be. And it’s only a few weeks back that I learned that Tolkien shared the same notion. He said that Sauron started out as a perfectionist which desired order, and in his effort to make things perfect he became the embodiment of evil.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Seeking perfection is a fool’s errand.

Perfection is a fading spark that can only be achieved for oneself - never when working with others - and as soon as it’s gone the search for the new perfect begins.

By constantly searching for perfection you end up living in the fantastic future - in the...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/22</id>
    <published>2022-10-29T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/what-is-a-professional-tool-anyway-55xrWHjz6gDb"/>
    <title>What is a professional tool anyway?</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;One weekend in 2017 I was using my Macbook Pro when all of a sudden the screen turned black and it started spewing white smoke like it had just elected the new Pope.&lt;br&gt;&lt;br&gt;Luckily, it didn’t catch fire. Later that day I opened up the laptop to see what happened.&lt;br&gt;&lt;br&gt;To my surprise it wasn’t the battery, but it was the hard drive - the only replaceable component in a 2013 Macbook Pro.&lt;br&gt;&lt;br&gt;I ordered a replacement, and a week later the laptop was working again. I even upgraded it’s storage from 512GB to 1TB.&lt;br&gt;&lt;br&gt;But this problem got me thinking - was the MacBook Pro really a professional tool?&lt;br&gt;&lt;br&gt;I always think of a DSLR camera when I think of a professional tool.&lt;br&gt;&lt;br&gt;Its parts are mostly replaceable, and the parts that aren’t have redundancies.&lt;br&gt;&lt;br&gt;You can switch out lenses, flashes, microphones. You can look through the view finder or the LCD to see what you are shooting. All settings can be changed using the buttons on the device or through the on-screen menu. The lenses, flashes and microphones are interoperable with newer and older camera bodies.&lt;br&gt;&lt;br&gt;All of these functions give the tool a long service life and make it reliable and dependable. Therefore a higher price is justified and still a good investment for a professional user.&lt;br&gt;&lt;br&gt;Yet there still exist cheaper point-and-shoots which offer similar image quality but without the modularity for less money.&lt;br&gt;&lt;br&gt;As a developer, my most used tool is a computer. And for a long time my go to computer was a laptop.&lt;br&gt;&lt;br&gt;In the laptop space modularity and repairability have been dead and buried long ago.&lt;br&gt;&lt;br&gt;The ability to add more memory or space to your machine, to repair parts that break, has been sacrificed in the name of “progress” and “design”.&lt;br&gt;&lt;br&gt;The question is the progress of what? And what design?&lt;br&gt;&lt;br&gt;The hardware was never smaller and more reliable than today. Machines that were thin, slick, and modular yesteryear are just thin and slick today. &lt;a href="https://www.ifixit.com/products/macbook-air-original-mid-2009-replacement-battery?variant=39371742150759"&gt;The original MacBook Air was a few millimetres thicker than today’s and had a replaceable battery.&lt;/a&gt;&lt;br&gt;&lt;br&gt;The only progress I see here is the progress of companies maximizing profits.&lt;br&gt;&lt;br&gt;The simple truth is that it’s cheaper to make hardware this way. And it forces people to buy the more expensive model up front out of fear that they might outgrow the cheaper one.&lt;br&gt;&lt;br&gt;It used to be normal to buy a machine with moderate specs and upgrade it later if you wanted more memory or space. Hell, I upgraded my old 2007 iMac with more memory and an SSD back in 2009 - these machines used to be user-upgradeable.&lt;br&gt;&lt;br&gt;Today it’s becoming harder and harder to find a computer that allows you to fix or upgrade anything.&lt;br&gt;&lt;br&gt;Some companies even go out of their way to make repairs and upgrades difficult. Probably to up-sell you a more expensive model or to sell you a new device when the first one fails due to a banal problem.&lt;br&gt;&lt;br&gt;I wouldn’t call these devices professional.&lt;br&gt;&lt;br&gt;Sure, they have a lot of features, and they have cutting edge specs. But just like with the point-and-shoot camera, you get what you get and once something breaks the whole machine becomes a paperweight.&lt;br&gt;&lt;br&gt;In the last few years a few companies that offer upgradeable and repairable laptops did appear, and I wish them the best of luck. We could use more companies like these, and more devices that aren’t designed to fail, that are repairable and upgradable.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">One weekend in 2017 I was using my Macbook Pro when all of a sudden the screen turned black and it started spewing white smoke like it had just elected the new Pope.

Luckily, it didn’t catch fire. Later that day I opened up the laptop to see what happened.

To my surprise it wasn’t the battery,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/21</id>
    <published>2022-10-23T12:00:00Z</published>
    <updated>2026-03-09T08:42:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/a-week-in-helsinki-3XbBLSiAuFKy"/>
    <title>A week in Helsinki</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Last Wednesday my girlfriend and I woke up at 4 AM and headed to the Zagreb Airport to catch a flight to Helsinki.&lt;br&gt;&lt;br&gt;I haven’t been in Helsinki since &lt;a href="https://www.hackjunction.com/"&gt;Junction 2016&lt;/a&gt;, but with &lt;a href="https://euruko.org/"&gt;EuRuKo 2022&lt;/a&gt; (the European Ruby Konference - this is not a typo) being there this was the prefect opportunity to visit again.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI5MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--bfb348c937f1a2d8d16a8fe779da39ce34dec006" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaU1CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--7bcbe31b29b9cdc16ed919cb9637835389bbb07b/231529FF-ACE2-41AC-9B92-17DF332FF25F.jpeg" filename="231529FF-ACE2-41AC-9B92-17DF332FF25F.jpeg" filesize="1097912" width="3024" height="4032" previewable="true" caption="Above the clouds somewhere between Zagreb and Munich"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Above the clouds somewhere between Zagreb and Munich" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjkxLCJwdXIiOiJibG9iX2lkIn19--2ce32c250112e69ab46c198eb2a089ef800ab4ec/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/231529FF-ACE2-41AC-9B92-17DF332FF25F.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Above the clouds somewhere between Zagreb and Munich
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;One hour after boarding our flight in Zagreb we were in Munich where we had a 40min layover. I though we would have to run through the airport to catch the next flight, but to my surprise there was a person with a “Helsinki” sign waiting for us just as we stepped off the airplane.&lt;br&gt;&lt;br&gt;They took us with a van from one part of the airport to a small border crossing to check our passports. This confused me at first, but it occurred to me later that it was due to the Schengen area rules. Croatia isn’t a member yet so we have to go through passport control.&lt;br&gt;&lt;br&gt;After the passport check the man took out a key and started unlocking a door that until that point I didn’t even notice existed. This was the first in a series of doors that he opened. Each lead to a hallway that connected to a staircase or elevator and then to another locked door that eventually spit us out right next to our gate. It felt like we were going through a pocket dimension or a worm hole.&lt;br&gt;&lt;br&gt;A trip that would take about 40 to 50 min of running through the airport was cut short to just 30 min of inter-dimensional airport travel.&lt;br&gt;&lt;br&gt;The next flight wasn’t as exciting as the previous one. We landed in Helsinki some two hours later and went to the baggage clam to find that our luggage went on trip of it’s own, but neither we nor Lufthansa knew where to. Thanks Lufthansa.&lt;br&gt;&lt;br&gt;A taxi ride later we were in our hotel. With nothing to unpack we met up with a few friends and went for a walk towards the city center.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--27a81e2f3950b3f2c5e51bfed8306f36e4f1997d" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBak1CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--fca6581bac6e25e0346a3a0616d2e2053711a24b/8DC90C0D-A958-423E-AF94-84005DC5E9A9.jpeg" filename="8DC90C0D-A958-423E-AF94-84005DC5E9A9.jpeg" filesize="4741592" width="3024" height="4032" previewable="true" caption="Statues at the Helsinki railway station"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Statues at the Helsinki railway station" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA3LCJwdXIiOiJibG9iX2lkIn19--93c5dddd86e9eb8d1aafac12db3cbe4ee625fc2b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/8DC90C0D-A958-423E-AF94-84005DC5E9A9.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Statues at the Helsinki railway station
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The walk turned into lunch at the Zetro - a tractor (farming machinery) themed restaurant. Inside which everything is dimly lit, there are tractor tables, and all kinds of farming equipment. When you sit at a table the waiters light a few candles without which it would be hard to read the menu.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwOD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--44aba93898246563f79ae3715f57a43542be98a9" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBalFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--06ec68eb1548720189cd6dda6fb07893415486f0/E932F629-A185-495F-9E8B-5429BAD99FFC.jpeg" filename="E932F629-A185-495F-9E8B-5429BAD99FFC.jpeg" filesize="1136309" width="2132" height="3530" previewable="true" caption="A tractor turned into a dining table in a room dimly lit by red light"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A tractor turned into a dining table in a room dimly lit by red light" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA4LCJwdXIiOiJibG9iX2lkIn19--708dbef7e65a85d26936055882ec82c1347b125e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/E932F629-A185-495F-9E8B-5429BAD99FFC.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A tractor turned into a dining table in a room dimly lit by red light
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Everything on the menu has a cheeky name and a funny description. I ordered the “Momma’s boy meet balls”, while my girlfriend ordered a steak that is allegedly cooked on the bonnet of a Zetro tractor. Both meals were amazing as was the service.&lt;br&gt;&lt;br&gt;The following day was the first day of EuRuKo.&lt;br&gt;&lt;br&gt;The organizers really outdid themselves. The venue was the &lt;a href="https://en.wikipedia.org/wiki/Paasitorni"&gt;Paasitorni&lt;/a&gt;, a beautiful and ornate workers’ house from the early 1900s. Registration was quick. There weren’t queues for food, water or hot beverages - what more can you ask for?&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwOT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--0dd26d6e42995b1e6e717fdaf0beea22dfd02af9" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBalVCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--33d93843dbdcd4c8f21f13712c8cc3464346da51/4319989F-6CE8-44CD-B105-D9C52271C8A2.jpeg" filename="4319989F-6CE8-44CD-B105-D9C52271C8A2.jpeg" filesize="2897524" width="2887" height="3926" previewable="true" caption="Inside of the ornate room where the conference was held"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Inside of the ornate room where the conference was held" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA5LCJwdXIiOiJibG9iX2lkIn19--a255db6a7d9861e09fed7b8d300b5126cfa0c980/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/4319989F-6CE8-44CD-B105-D9C52271C8A2.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Inside of the ornate room where the conference was held
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;All talks are &lt;a href="https://2022.euruko.org/videos/"&gt;already available for everyone to watch&lt;/a&gt;.&lt;br&gt;&lt;br&gt;My favorites are &lt;a href="https://www.youtube.com/watch?v=MtweO89OsUM"&gt;How music works, using Ruby by Thijs Cadier&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=2aVyTtxs0GU"&gt;Implementing Object Shapes in CRuby by Jemma Issroff&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=cNLscH0sGz4"&gt;The Technical and Organizational Infrastructure of the Ruby Community by Adarsh Pandit&lt;/a&gt; and &lt;a href="https://www.youtube.com/watch?v=gnX1o8GBed0"&gt;Looking Into Peephole Optimizations by Maple Ong&lt;/a&gt;.&lt;br&gt;&lt;br&gt;But the magic on conferences is meeting people. I had many wonderful conversations with various people about how they run their applications (thanks Shopify team), and about the internals of the Ruby virtual machine. Exciting times are ahead for Ruby with version 3.2 and beyond.&lt;br&gt;&lt;br&gt;After the conference we went out for dinner and found a newly opened Korean barbecue named Oppa. This was our first time in a Korean BBQ and it was an experience. We had so much fun, and the food was so delicious, that we came back the next evening with a whole group of friends.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMxMD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--5e35bfce2da04cbdc94f06edbfd1d39c6d4df76e" content-type="video/mp4" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBallCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--778c9c3e098c54c0771774545f1f50b369b5f18d/koreanbbq2.mp4" filename="koreanbbq2.mp4" filesize="19401846" width="1080.0" height="1920.0" previewable="true" caption="A few pieces of meat on a Korean barbecue plate"&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt;

    &lt;video controls="controls" autoplay="autoplay" playsinline="playsinline" loop="loop" muted="muted" loading="lazy" poster="/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzEwLCJwdXIiOiJibG9iX2lkIn19--4f79370eafdf519450910a8eb387fc8c3e7ef884/eyJfcmFpbHMiOnsiZGF0YSI6eyJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--1cc31e49528e9f1b78102e6af6461855986a471e/koreanbbq2.mp4" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzEwLCJwdXIiOiJibG9iX2lkIn19--4f79370eafdf519450910a8eb387fc8c3e7ef884/koreanbbq2.mp4"&gt;&lt;/video&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A few pieces of meat on a Korean barbecue plate
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;On Saturday we took a ferry to &lt;a href="https://en.wikipedia.org/wiki/Suomenlinna"&gt;Suomenlinna&lt;/a&gt;, an island some 10 min away from the city center.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMxMT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--8e0589d7c84e12540fc2120233b395898b249bf3" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBamNCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f9611eb40b8e6bdf86ed7836c94420fee9e735a/2DA6F8A1-576F-46C8-9937-51E4EDD3F5AA.jpeg" filename="2DA6F8A1-576F-46C8-9937-51E4EDD3F5AA.jpeg" filesize="1074528" width="1536" height="2048" previewable="true" caption="A view of Helsinki from the harbor"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A view of Helsinki from the harbor" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzExLCJwdXIiOiJibG9iX2lkIn19--ee41c1e6f2e6d5b6dc5cd482915cd84f94cfcc28/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/2DA6F8A1-576F-46C8-9937-51E4EDD3F5AA.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A view of Helsinki from the harbor
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;We saw a few small islands with a single house along the way. It must be wonderful to live in such a beautiful, secluded, wooden house, only 10 min away from anything you might need or want to visit.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMxMj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--b04561fed7f8d61cafc49762be76709ed4a83634" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBamdCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--85d3c5983ef5802c82235605852f5d1c15af0506/509D914E-75C1-401E-BF39-579088CBDA86.jpeg" filename="509D914E-75C1-401E-BF39-579088CBDA86.jpeg" filesize="955793" width="2048" height="1536" previewable="true" caption="A beautiful, red painted, wooden house on a small island right outside the Helsinki harbor"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A beautiful, red painted, wooden house on a small island right outside the Helsinki harbor" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzEyLCJwdXIiOiJibG9iX2lkIn19--c2807a9ca6ba8dbc4fa1d2aa8d031bb8290bac87/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/509D914E-75C1-401E-BF39-579088CBDA86.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A beautiful, red painted, wooden house on a small island right outside the Helsinki harbor
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;As we stepped off the ferry we saw a sign on a pinkish-red building that said the island is an UNESCO World Heritage site. It houses the naval academy, a former ship yard, a former prison and a fortress that used to protect the city.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMxMz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--47fd660256147df44627239001386e0abf4c5e46" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBamtCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--fe994e75e5ffa4bad157f4ba128ed712f5c67d81/DE8FA187-7DBE-4642-AC2A-1699C91CC2AC.jpeg" filename="DE8FA187-7DBE-4642-AC2A-1699C91CC2AC.jpeg" filesize="5681729" width="2975" height="4032" previewable="true" caption="A pinkish-red tower of a building with a clock in it"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A pinkish-red tower of a building with a clock in it" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzEzLCJwdXIiOiJibG9iX2lkIn19--5f5bf532913e83354050fb51ec1b48aab0962ec1/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/DE8FA187-7DBE-4642-AC2A-1699C91CC2AC.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A pinkish-red tower of a building with a clock in it
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The first thing that stood out on this gloomy autumn morning was the bright yellow naval academy. So we went to it first and then returned to the harbor.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMxND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--32071b5637f721beeb1d37480514741c08569683" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBam9CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d5a2540d975523fa52ef0ed334a815510312396d/0134EBCC-6453-4947-B2ED-5C49323C49C7.jpeg" filename="0134EBCC-6453-4947-B2ED-5C49323C49C7.jpeg" filesize="4525627" width="3024" height="4032" previewable="true" caption="Two bright-yellow buildings of the naval academy"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Two bright-yellow buildings of the naval academy" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzE0LCJwdXIiOiJibG9iX2lkIn19--da706c1abc81f473693586574869ca1106c760e4/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/0134EBCC-6453-4947-B2ED-5C49323C49C7.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Two bright-yellow buildings of the naval academy
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The harbor and coastal regions of the island are littered with small wooden homes with a few brick and mortar buildings thrown in here and there. Many of the houses are private, some are hostels, museums, cafes and shops.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwMz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--bd38bb3ca0811bf0d78dd37c681c4f943c2000ae" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaThCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4c4f22031e93ca8ae526298db9fa65fe23582b59/B087AE75-5014-4173-BB57-3B3D841ECF39.jpeg" filename="B087AE75-5014-4173-BB57-3B3D841ECF39.jpeg" filesize="1731128" width="1536" height="2048" previewable="true" caption="A brick and mortar building from the center of the island."&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A brick and mortar building from the center of the island." loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzAzLCJwdXIiOiJibG9iX2lkIn19--7a12cc72245b6f2ae26c5c1d9c1da8a086f2d9ae/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/B087AE75-5014-4173-BB57-3B3D841ECF39.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A brick and mortar building from the center of the island.
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;We visited the military history museum which is split across two buildings (keep the ticket when you enter the first building so that you can enter the next).&lt;br&gt;&lt;br&gt;One building is full of military uniforms and equipment all the way from the early 1900s to WWII. While the second building is full of modern military equipment.&lt;br&gt;&lt;br&gt;In the second building you can wear some of the historic uniforms and sit in the cockpit of a jet fighter simulator. Needles to say we had a fashion show.&lt;br&gt;&lt;br&gt;And there is even a submarine!&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI5Nz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--73a9c7faa6c8bb366ac000ac3fa6bce9de805180" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaWtCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3d5710fbfb64354511d31f2847cfaf0142ed1142/71E79C51-1865-4568-9624-52BF6F67A3B1.jpeg" filename="71E79C51-1865-4568-9624-52BF6F67A3B1.jpeg" filesize="942990" width="1536" height="2048" previewable="true" caption="A submarine in a dry dock"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A submarine in a dry dock" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjk3LCJwdXIiOiJibG9iX2lkIn19--7dbef4e0313fd6966ab42e6d3b07ddf18e6a358c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/71E79C51-1865-4568-9624-52BF6F67A3B1.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A submarine in a dry dock
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;After the museum we went for a walk around the island - which is full of hills, most of which have been hollowed out to make bunkers, caches and armories.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--3124b4549f076d6d11d15fecfb1d4748f0cd9cda" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBakFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--347e63767db4228daef52e1da8b0b7b7ad1aa2c2/D757BF2A-7099-43B2-9F03-592B95477073.jpeg" filename="D757BF2A-7099-43B2-9F03-592B95477073.jpeg" filesize="2988215" width="2972" height="3963" previewable="true" caption="The inside of a hollowed out hill"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The inside of a hollowed out hill" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA0LCJwdXIiOiJibG9iX2lkIn19--49cbd901e36fd40cb9e7a05e8d7f9dee586a0fd2/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/D757BF2A-7099-43B2-9F03-592B95477073.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The inside of a hollowed out hill
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;To me the landscape was magical. I never imagined that an island could be covered with beech and oak trees on rolling green hills that end with granite rock cliffs in the dark blue sea. My mental image of an island or the seaside is Dalmatia - steep mountainous terrain with sharp white rocks covered in pine and olive forests ending in a teal-azure sea with pebbled beaches.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI5OD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--47524ac0326114151585c69d1fe7987ced2c88e1" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaW9CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--56c9f24b5010413c4dc24cc4cfc1bb61306e9923/44D7DE29-E92C-4CD2-A8A9-CF12FA142692.jpeg" filename="44D7DE29-E92C-4CD2-A8A9-CF12FA142692.jpeg" filesize="5062488" width="4032" height="3024" previewable="true" caption="A cliff covered with in green moss, grass, and trees, looking over the sea"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A cliff covered with in green moss, grass, and trees, looking over the sea" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjk4LCJwdXIiOiJibG9iX2lkIn19--ce1353cab0012053eac36c1b6468737ce821920e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/44D7DE29-E92C-4CD2-A8A9-CF12FA142692.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A cliff covered with in green moss, grass, and trees, looking over the sea
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;We went past the huge defensive walls, the canons looking over the harbor, through the fortress, all the way to the king’s gate and then back to the harbor building - where we sat down in a brewery to wait for the ferry back.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwNT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--cee4b39f492025f5c37d03b0046a88b81f580ff6" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBakVCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--2a7ed3ac96ecff3c0c4bde6e2f0e1653b9d380db/99956906-1E18-48AA-95EB-00D3CE3ED208.jpeg" filename="99956906-1E18-48AA-95EB-00D3CE3ED208.jpeg" filesize="5835033" width="2954" height="4030" previewable="true" caption="A green bench in front of a large defensive wall made of many gray, dark red and dark blue granite rocks topped off with a brick and mortar gable"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A green bench in front of a large defensive wall made of many gray, dark red and dark blue granite rocks topped off with a brick and mortar gable" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA1LCJwdXIiOiJibG9iX2lkIn19--44221ccd291049a8c281f6606a613664d1bdaefe/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/99956906-1E18-48AA-95EB-00D3CE3ED208.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A green bench in front of a large defensive wall made of many gray, dark red and dark blue granite rocks topped off with a brick and mortar gable
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Back in Helsinki we went to the cathedral and then went back to the hotel to pack our bags for the trip back home.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI5OT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a026dda34f5ef849250358e3a25de00a404dfedb" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaXNCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d40a30e51c5cb40776a571047d27fb525b271e52/266F023D-1751-4CC4-8D93-18349911407D.jpeg" filename="266F023D-1751-4CC4-8D93-18349911407D.jpeg" filesize="850217" width="1536" height="2048" previewable="true" caption="The Helsinki Cathedral - a tall, white palace with green domed roofs decorated with golden stars"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The Helsinki Cathedral - a tall, white palace with green domed roofs decorated with golden stars" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjk5LCJwdXIiOiJibG9iX2lkIn19--5e350f1dc5c3a3a16387953856c589e680a2d9a3/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/266F023D-1751-4CC4-8D93-18349911407D.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Helsinki Cathedral - a tall, white palace with green domed roofs decorated with golden stars
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Helsinki is a clean, beautiful, peaceful city, filled with beautiful buildings, wonderful parks, wide streets and cozy places. Given the opportunity I would move there in a heartbeat.&lt;br&gt;&lt;br&gt;Everyone we met was kind, helpful and trusting.&lt;br&gt;&lt;br&gt;I was surprised how quiet everybody is. Compared to my hometown everyone seems to be whispering all the time.&lt;br&gt;&lt;br&gt;Coming from a country where people are quite distrusting and a bit paranoid, the trusting nature of the people in Helsinki felt odd. There was nobody to check our ferry tickets, no badge or person to check if we were conference attendees, no check to see if we were hotel guests when we went for breakfast. Croats often joke that we are the world’s best nation in circumventing rules and finding loopholes. In contrast, everyone in Helsinki seems to trusts others to be decent human beings.&lt;br&gt;&lt;br&gt;I can’t wait to visit the home of the &lt;a href="https://en.wikipedia.org/wiki/Moomins"&gt;Moomins&lt;/a&gt; again.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzMwNj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--42d00ae6cf0b94d1ec3ea5cf2601a99f3563502f" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaklCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--cb883b68be5b128f1b2e48cdae8df8269604a3b2/A445D99B-C5D7-43D7-8095-1111C2C4F2DF.jpeg" filename="A445D99B-C5D7-43D7-8095-1111C2C4F2DF.jpeg" filesize="5387350" width="2859" height="3902" previewable="true" caption="A white notebook with a gold embossed Moomin"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A white notebook with a gold embossed Moomin" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzA2LCJwdXIiOiJibG9iX2lkIn19--cfba961672026806543c5f82929bd098e21dca7f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/A445D99B-C5D7-43D7-8095-1111C2C4F2DF.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A white notebook with a gold embossed Moomin
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last Wednesday my girlfriend and I woke up at 4 AM and headed to the Zagreb Airport to catch a flight to Helsinki.

I haven’t been in Helsinki since Junction 2016, but with EuRuKo 2022 (the European Ruby Konference - this is not a typo) being there this was the prefect opportunity to visit...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/20</id>
    <published>2022-10-10T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/how-an-index-made-rendering-slow-4SwhrHpds4Te"/>
    <title>How an index made rendering slow</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;I noticed that a view I was working on was rendering much slower than I would expect it to. The view showed a list of events, together with the person that generated the event and the device that the event belongs to. It took nearly half a second to render 25 events, while other similar pages render in a couple of milliseconds.&lt;br&gt;&lt;br&gt;At first I thought that I had an N+1 - that each rendered event went to fetch its person and device from the database, generating 2 database queries per rendered row.&lt;br&gt;&lt;br&gt;But in the controller the associations were clearly &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-preload"&gt;preloaded&lt;/a&gt;, and even &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-strict_loading"&gt;marked for strict loading&lt;/a&gt;. Not only was an N+1 problem avoided, but the system would raise an error if one would occur.&lt;/p&gt;&lt;pre data-language="ruby"&gt;def index&lt;br&gt;  @events = Device::EventLog.all&lt;br&gt;                            .preload(:user, :device)&lt;br&gt;                            .strict_loading&lt;br&gt;                            .page(params[:page])&lt;br&gt;                            .per(25)&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;So why did the view render so slow?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;I went to look at the development server logs - which, in Rails, show a lot of useful information for debugging problems like these - and I saw this:&lt;/p&gt;&lt;pre data-language="plain"&gt;Started GET "/events" for 172.23.0.1 at 2022-10-10 08:57:51 +0000&lt;br&gt;Processing by EventsController#index as HTML&lt;br&gt;  Parameters: {}&lt;br&gt;&lt;br&gt;...&lt;br&gt;&lt;br&gt;Device::EventLog Load (2.8ms)  SELECT `device_event_logs`.* FROM `device_event_logs` LIMIT 25 OFFSET 0&lt;br&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040'&lt;br&gt;&lt;br&gt;...&lt;br&gt;&lt;br&gt;User Load (79.75ms)  SELECT `users`.* FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, ...)&lt;br&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040'&lt;br&gt;&lt;br&gt;...&lt;br&gt;&lt;br&gt;Device Load (79.95ms)  SELECT `devices`.* FROM `devices` WHERE `devices`.`deleted_at` IS NULL AND `devices`.`id` IN (8368, 137, ...)&lt;br&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040'&lt;br&gt;&lt;br&gt;...&lt;br&gt;&lt;br&gt;Completed 200 OK in 251ms (Views: 78.7ms | ActiveRecord: 162.5ms | Allocations: 226640)&lt;/pre&gt;&lt;p&gt;It was clearly visible that the preload worked. ActiveRecord made three queries to the database - one to get the events, another to get the users, and a third to get the devices.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;But where does the WHERE deleted_at IS NULL come from?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;I looked at the &lt;b&gt;&lt;strong&gt;User&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;Device&lt;/strong&gt;&lt;/b&gt; models. Both invoke the &lt;b&gt;&lt;strong&gt;acts_as_paranoid&lt;/strong&gt;&lt;/b&gt; method which the &lt;a href="https://github.com/rubysherpas/paranoia"&gt;paranoia gem&lt;/a&gt; adds to &lt;b&gt;&lt;strong&gt;ActiveRecord::Record&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;We use that gem at work to soft delete records in the database - the data is kept in the table, it just isn’t returned unless specifically requested.&lt;br&gt;&lt;br&gt;Paranoia knows which records are deleted by checking a &lt;b&gt;&lt;strong&gt;deleted_at&lt;/strong&gt;&lt;/b&gt; column that has to be added to each model that can act as paranoid. When the record is deleted, &lt;b&gt;&lt;strong&gt;deleted_at&lt;/strong&gt;&lt;/b&gt; is set to the current timestamp. A record can be restored (un-deleted) by setting &lt;b&gt;&lt;strong&gt;deleted_at&lt;/strong&gt;&lt;/b&gt; back to &lt;b&gt;&lt;strong&gt;nil&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Paranoia filters out deleted records by adding &lt;b&gt;&lt;strong&gt;WHERE deleted_at IS NULL&lt;/strong&gt;&lt;/b&gt; to all queries.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Why are the queries to fetch the users and devices so slow?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;In situations like these I reach for &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-explain"&gt;the &lt;b&gt;&lt;strong&gt;explain&lt;/strong&gt;&lt;/b&gt;&amp;nbsp;method on an ActiveRecord relation&lt;/a&gt;. It tells the database to explain how it will execute the query. This shows important information like the indices it plans to use, in which order it wants to filter rows, and more.&lt;br&gt;&lt;br&gt;Here is the explain result for one of the slow queries:&lt;/p&gt;&lt;pre data-language="ruby"&gt;User.where(id: [&lt;br&gt;  97212, 2526, 15027, 11982, 10846,&lt;br&gt;  17091, 19528, 10731, 7785, 23240,&lt;br&gt;  23238, 7244, 7221, 6993, 11959,&lt;br&gt;  16931, 38234, 7006, 8993, 6766,&lt;br&gt;  39565, 31681, 7088, 7786, 34029&lt;br&gt;]).explain&lt;br&gt;#   User Load (79.75ms)  SELECT `users`.* FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, 15027, 11982, 10846, 17091, 19528, 10731, 7785, 23240, 23238, 7244, 7221, 6993, 11959, 16931, 38234, 7006, 8993, 6766, 39565, 31681, 7088, 7786, 34029)&lt;br&gt;# =&amp;gt; EXPLAIN for: SELECT `users`.* FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, 15027, 11982, 10846, 17091, 19528, 10731, 7785, 23240, 23238, 7244, 7221, 6993, 11959, 16931, 38234, 7006, 8993, 6766, 39565, 31681, 7088, 7786, 34029)&lt;br&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;br&gt;# | id | select_type | table | type | possible_keys                     | key                       | key_len | ref   | rows | Extra                 |&lt;br&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;br&gt;# |  1 | SIMPLE      | users | ref  | PRIMARY,index_users_on_deleted_at | index_users_on_deleted_at | 6       | const | 25   | Using index condition |&lt;br&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;br&gt;# 1 row in set (0.00 sec)&lt;/pre&gt;&lt;p&gt;The result shows that the database sees two indices with which it can filter the table to generate the result - &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Out of these two it choose to use &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt;, which is a bit odd since the query mostly filters by IDs.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Why did it choose that index instead of using the primary key? And why is that so slow?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;I ran &lt;b&gt;&lt;strong&gt;SHOW INDEX&lt;/strong&gt;&lt;/b&gt;, an introspection tool in MariaDB that shows statistics about indices on a table. The query planner uses those same statistics to plan how to filter the requested rows. Understanding what the query planner sees can explain why MariaDB picked &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt; instead of &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;These are the statistics for the &lt;b&gt;&lt;strong&gt;users&lt;/strong&gt;&lt;/b&gt; table:&lt;/p&gt;&lt;pre data-language="plain"&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+----&lt;br&gt;| Table   | Non_unique | Key_name                    | Seq_in_index | Column_name  | Collation | Cardinality | ...&lt;br&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+-----&lt;br&gt;| "users" | 0          | "PRIMARY"                   | 1            | "id"         | "A"       | 3202800     | ...&lt;br&gt;| "users" | 1          | "index_users_on_deleted_at" | 1            | "deleted_at" | "A"       | 4003        | ...&lt;br&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+-----&lt;/pre&gt;&lt;p&gt;The &lt;b&gt;&lt;strong&gt;Cardinality&lt;/strong&gt;&lt;/b&gt; column shows that the &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index has millions of values indexed, while &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt; only has a few thousand.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;But why is that index so slow?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;The decision by the query planner to use &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt; felt off. It seemed like a mistake so I dug deeper.&lt;br&gt;&lt;br&gt;MariaDB offers query optimizer tracing, which is fancy term for a “detailed explanation why it did what it did”.&lt;br&gt;&lt;br&gt;I opened a DB console and ran &lt;b&gt;&lt;strong&gt;SET optimizer_trace='enabled=on';&lt;/strong&gt;&lt;/b&gt; to enable tracing. Then I ran the slow query and right after that I checked to see the optimizer trace with &lt;b&gt;&lt;strong&gt;SELECT * FROM information_schema.optimizer_trace LIMIT 1;&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;This returned a large JSON object, which showed every decision that the query planner made and explained why it chose the approach it did.&lt;br&gt;&lt;br&gt;The trace showed that the &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index had a much lower execution cost (was much faster) than &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt;, but it chose to use &lt;b&gt;&lt;strong&gt;index_users_on_deleted_at&lt;/strong&gt;&lt;/b&gt; for “rowid filtering”. I had no clue what “rowid filtering” was so I &lt;a href="https://mariadb.com/kb/en/rowid-filtering-optimization/"&gt;went to look it up in the documentation&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Row ID filtering is an optimization. The basic idea is this - if you have two indices on a table, an you are filtering by both; more often than not it’s quicker to filter by the more restrictive index first, and then scan the result to filter by the less restrictive one.&lt;br&gt;&lt;br&gt;Or in other words, it’s better to pick the index that eliminates most values first so that you have fewer rows to work with, and then scan through the result and filter by any other filters. Makes sense.&lt;br&gt;&lt;br&gt;MariaDB, unlike PostgreSQL, doesn’t support conditional indexing. This means that each index you add to a table indexes the whole table.&lt;br&gt;&lt;br&gt;This is great when you have many rows with different values in the indexed column, but it can backfire when you have a table that has many rows with the same value.&lt;br&gt;&lt;br&gt;In my case the &lt;b&gt;&lt;strong&gt;deleted_at&lt;/strong&gt;&lt;/b&gt; column has around 4k different values in about 3 million rows. But a single value - &lt;b&gt;&lt;strong&gt;NULL&lt;/strong&gt;&lt;/b&gt; - of those 4k is associated with nearly all 3 million rows. This means that the database will load and scan through 3 million records to find the ones matching the &lt;b&gt;&lt;strong&gt;IN&lt;/strong&gt;&lt;/b&gt; clause - which is extremely slow.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;How do I make the query fast?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;Luckily MariaDB, unlike PostgreSQL, provides an escape hatch for situations like this. Through the &lt;b&gt;&lt;strong&gt;USE INDEX&lt;/strong&gt;&lt;/b&gt; option, it allows you to specify the index it should use when filtering.&lt;br&gt;&lt;br&gt;ActiveRecord doesn’t have a method for &lt;b&gt;&lt;strong&gt;USE INDEX&lt;/strong&gt;&lt;/b&gt; but it can be added by passing a string to the &lt;b&gt;&lt;strong&gt;from&lt;/strong&gt;&lt;/b&gt; method.&lt;br&gt;&lt;br&gt;Telling the planner to use the &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index speeds up the query drastically. The execution time fell from 80 ms to 1 ms.&lt;/p&gt;&lt;pre data-language="ruby"&gt;User.from('users USE INDEX(PRIMARY)')&lt;br&gt;    .where(id: [97212, 2526, 15027, 11982, 10846, 17091, 19528, 10731, 7785, 23240, 23238, 7244, 7221, 6993, 11959, 16931, 38234, 7006, 8993, 6766, 39565, 31681, 7088, 7786, 34029])&lt;br&gt;    .explain&lt;br&gt;#   User Load (0.7ms)  SELECT `users`.* FROM users USE INDEX(PRIMARY) WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, 15027, 11982, 10846, 17091, 19528, 10731, 7785, 23240, 23238, 7244, 7221, 6993, 11959, 16931, 38234, 7006, 8993, 6766, 39565, 31681, 7088, 7786, 34029)&lt;br&gt;# =&amp;gt; EXPLAIN for: SELECT `users`.* FROM users USE INDEX(PRIMARY) WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, 15027, 11982, 10846, 17091, 19528, 10731, 7785, 23240, 23238, 7244, 7221, 6993, 11959, 16931, 38234, 7006, 8993, 6766, 39565, 31681, 7088, 7786, 34029)&lt;br&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;br&gt;# | id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra       |&lt;br&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;br&gt;# |  1 | SIMPLE      | users | range | PRIMARY       | PRIMARY | 8       | NULL | 25   | Using where |&lt;br&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;br&gt;# 1 row in set (0.00 sec)&lt;/pre&gt;&lt;p&gt;But this doesn’t solve my problem. I can’t call the &lt;b&gt;&lt;strong&gt;from&lt;/strong&gt;&lt;/b&gt; method in a preload query - that query is built and executed by ActiveRecord internally.&lt;br&gt;&lt;br&gt;Though, now that I understand the problem I can trick the query planner into doing what I want.&lt;br&gt;&lt;br&gt;If I replace &lt;b&gt;&lt;strong&gt;preload&lt;/strong&gt;&lt;/b&gt; with &lt;b&gt;&lt;strong&gt;eager_load&lt;/strong&gt;&lt;/b&gt; I can force the query planner to use at least one &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index and thereby speed things up considerably.&lt;/p&gt;&lt;pre data-language="ruby"&gt;Device::EventLog.all&lt;br&gt;                .eager_load(:user, :device)&lt;br&gt;                .strict_loading&lt;br&gt;                .explain&lt;br&gt;#   Device::EventLog Load (2.7ms)  ...&lt;br&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;br&gt;# | id | select_type | table             | type   | possible_keys                       | key     | key_len | ref                                     | rows | Extra       |&lt;br&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;br&gt;# |  1 | SIMPLE      | device_event_logs | ALL    | NULL                                | NULL    | NULL    | NULL                                    | 25   | Using where |&lt;br&gt;# |  1 | SIMPLE      | users             | eq_ref | PRIMARY,index_users_on_deleted_at   | PRIMARY | 8       | development.device_event_logs.user_id   | 25   | Using where |&lt;br&gt;# |  1 | SIMPLE      | devices           | eq_ref | PRIMARY,index_devices_on_deleted_at | PRIMARY | 8       | development.device_event_logs.device_id | 25   | Using where |&lt;br&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;br&gt;&lt;br&gt;&lt;/pre&gt;&lt;p&gt;And it worked! Both &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; indices were used when filtering, and the query returns in 3 ms.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Why did this work?&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;I had a hunch that a &lt;b&gt;&lt;strong&gt;LEFT OUTER JOIN&lt;/strong&gt;&lt;/b&gt; would force the optimizer to use at least one &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index on the joined tables. Because &lt;b&gt;&lt;strong&gt;eager_load&lt;/strong&gt;&lt;/b&gt; does the same thing as &lt;b&gt;&lt;strong&gt;preload&lt;/strong&gt;&lt;/b&gt;, in this case, but using a &lt;b&gt;&lt;strong&gt;LEFT OUTER JOIN&lt;/strong&gt;&lt;/b&gt; it was the perfect solution.&lt;br&gt;&lt;br&gt;And I was right. The query optimizer trace for the &lt;b&gt;&lt;strong&gt;eager_load&lt;/strong&gt;&lt;/b&gt; query showed that the optimizer tries to apply the same optimization as before, but its cost is higher than using the &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt; index.&lt;br&gt;&lt;br&gt;The &lt;b&gt;&lt;strong&gt;Device::EventLog&lt;/strong&gt;&lt;/b&gt; table doesn’t have any more restrictive index that is also used in the query so it can’t apply the same optimization. While the joined tables are implicitly filtered by so many IDs that the cost of using the &lt;b&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/b&gt;index goes way down compared to the optimization.&lt;br&gt;&lt;br&gt;At least that’s what I think is going on.&lt;br&gt;&lt;br&gt;Either way, sometimes replacing &lt;b&gt;&lt;strong&gt;preload&lt;/strong&gt;&lt;/b&gt; with &lt;b&gt;&lt;strong&gt;eager_load&lt;/strong&gt;&lt;/b&gt; can utilize indices better and therefore speed up query execution.&lt;br&gt;&lt;br&gt;P.S. Because of this problem I took a deep dive into query planners and optimizers in both PostgreSQL and MariaDB. This is a fascinating topic, a science on its own, and a world with very different opinions about what the “correct” solution is. It made me appreciate the “no hints” approach that PostgreSQL takes, but at the same time showed me the value of escape hatches that MariaDB provides. It made me see that there really is no one-size-fits-all solution for SQL databases - as always in software, you stick with what works for you.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">I noticed that a view I was working on was rendering much slower than I would expect it to. The view showed a list of events, together with the person that generated the event and the device that the event belongs to. It took nearly half a second to render 25 events, while other similar pages...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/19</id>
    <published>2022-10-07T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/140-million-rows-later-NiCShJ27Gj5a"/>
    <title>140 million rows later</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;At work, as part of a project I’m working on, I wanted to add a new reference to a table. ”Simple enough” - I thought. Spoiler alert, it wasn’t.&lt;br&gt;&lt;br&gt;Adding a reference from one table to another is straight forward in Rails.&lt;br&gt;&lt;br&gt;You create a migration using &lt;b&gt;&lt;strong&gt;rails generate&lt;/strong&gt;&lt;/b&gt; and then write in it something like &lt;b&gt;&lt;strong&gt;add_reference :device_event_logs, :door, foreign_key: true, null: true&lt;/strong&gt;&lt;/b&gt;. And then you run &lt;b&gt;&lt;strong&gt;rails migrate&lt;/strong&gt;&lt;/b&gt;. Boom. Done!&lt;br&gt;&lt;br&gt;This is where my problems began.&lt;br&gt;&lt;br&gt;The table I was adding the reference to holds event logs for all devices. Each event represents some kind of user interaction with the device. I know that there are a decent number of devices in the field, and a decent number of users in the system.&lt;br&gt;&lt;br&gt;Since adding a reference can lock the table (prevent reading or writing to it) until the migration is done I wanted to be sure that it wouldn’t cause any problems.&lt;br&gt;&lt;br&gt;I opened the DB console, entered &lt;b&gt;&lt;strong&gt;SELECT COUNT(1) FROM device_event_logs&lt;/strong&gt;&lt;/b&gt; and hit enter. A few seconds passed and nothing happened - “there must be a lot of records in the table” I thought. Then 30 more seconds passed and still no result - this was a bad omen. After 45 sec the query returned a count of 140 million rows.&lt;br&gt;&lt;br&gt;Yikes. This means that adding the reference would take quite a while and use up a decent chunk of storage space.&lt;br&gt;&lt;br&gt;Next I went to check how often this table is written to - to determine how big of an impact a few minutes of downtime would have. So I checked the statistics tables in MariaDB and the number of requests to the controller that the devices call to log events. There were around 30 inserts per second.&lt;br&gt;&lt;br&gt;Yikes again. This meant that 5 min of downtime would cause 9000 failed requests - potentially 9000 unhappy customers.&lt;br&gt;&lt;br&gt;I wanted to find a way to add this reference quickly, without locking the table and without using too much storage space.&lt;br&gt;&lt;br&gt;Digging through the Rails documentation for &lt;b&gt;&lt;strong&gt;add_reference&lt;/strong&gt;&lt;/b&gt; I didn’t find anything that could help me solve this, so I resorted to adding the reference manually, step by step.&lt;br&gt;&lt;br&gt;In Rails, a reference like &lt;b&gt;&lt;strong&gt;add_reference :device_event_logs, :door, foreign_key: true, null: true&lt;/strong&gt;&lt;/b&gt; creates a column to store the ID of the row being referenced in the other table, creates and index on that column and adds a foreign key constraint from that column to the referenced table’s primary key.&lt;br&gt;&lt;br&gt;Now I read through MariaDB’s documentation to see how I could optimize each of these three steps.&lt;br&gt;&lt;br&gt;Turns out that adding the column is already optimized out of the box so I could just add it through &lt;b&gt;&lt;strong&gt;add_column :device_event_logs, :door_id, :integer, null: true&lt;/strong&gt;&lt;/b&gt;. That would create the column in less than a second and wouldn’t lock the table.&lt;br&gt;&lt;br&gt;When creating an index, the MariaDB docs say that one can specify an algorithm with which the index is generated. Some algorithms are locking, some aren’t, some create a copy of the whole table, some don’t. But if you don’t pass an algorithm MariaDB will try to use the fastest, least locking, and most space efficient one possible.&lt;br&gt;&lt;br&gt;Sounds great, but as I have been burned before by documentation leading to incorrect assumptions I decided to verify this.&lt;br&gt;&lt;br&gt;So I ran &lt;b&gt;&lt;strong&gt;CREATE INDEX foobar ON device_event_logs (door_id)&lt;/strong&gt;&lt;/b&gt; on our staging database. It took about 20 min to complete, increased the database’s size by nearly double and I couldn’t insert or select from the table while the index was being created.&lt;br&gt;&lt;br&gt;Good thing I checked.&lt;br&gt;&lt;br&gt;Then I tired specifying the algorithm explicitly with &lt;b&gt;&lt;strong&gt;CREATE INDEX foobar ON device_event_logs (door_id) ALGORITHM inplace&lt;/strong&gt;&lt;/b&gt; and it erred out with a message saying that &lt;b&gt;&lt;strong&gt;INPLACE&lt;/strong&gt;&lt;/b&gt; isn’t supported on this table. I also tried all other algorithms none of which were supported, except for the slow and locking one - &lt;b&gt;&lt;strong&gt;COPY&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;This threw me down a rabbit hole. I spent a day trying to figure out why the better algorithms weren’t supported until I finally found a paragraph in MariaDB’s documentation explaining that tables created prior to MariaDB version 10.something.something can’t use the newer algorithms. So I checked when the staging table was created, and sure enough, it was way before that version was released.&lt;br&gt;&lt;br&gt;Since the staging environment was setup after the production environment I’d surely run into the same problem there. What now?&lt;br&gt;&lt;br&gt;After some more digging I found out that this problem can be solved by running &lt;b&gt;&lt;strong&gt;OPTIMIZE TABLE device_event_logs&lt;/strong&gt;&lt;/b&gt;. Which will do all sorts of magic, fairly quickly and with a lock on the table, to get the table working with all features of the current MariaDB version. So I ran it, and a second later it was done.&lt;br&gt;&lt;br&gt;Now I could add the index with &lt;b&gt;&lt;strong&gt;add_index :device_event_logs, :door_id, algorithm: :inplace&lt;/strong&gt;&lt;/b&gt;. The algorithm option isn’t necessary, but I wanted the migration to fail if it would lock the table - in case I forgot to optimize it.&lt;br&gt;&lt;br&gt;When creating a foreign key constraint, the MariaDB docs say that, you can pass an algorithm (just like with an index), and a &lt;b&gt;&lt;strong&gt;LOCK=NONE&lt;/strong&gt;&lt;/b&gt; option. But &lt;b&gt;&lt;strong&gt;add_foreign_key&lt;/strong&gt;&lt;/b&gt; in rails accepts neither of these options, so I had to resort to writing SQL.&lt;br&gt;&lt;br&gt;But when I tried to run the migration it failed, stating that an index on the &lt;b&gt;&lt;strong&gt;doors&lt;/strong&gt;&lt;/b&gt; table’s &lt;b&gt;&lt;strong&gt;id&lt;/strong&gt;&lt;/b&gt; column was missing. What? How? It’s the primary key!&lt;br&gt;&lt;br&gt;After some more digging I found out that the column types in a foreign key have to match, else you get that error.&lt;br&gt;&lt;br&gt;I added an integer column to &lt;b&gt;&lt;strong&gt;device_event_logs&lt;/strong&gt;&lt;/b&gt; thinking it would automatically use &lt;b&gt;&lt;strong&gt;bigint&lt;/strong&gt;&lt;/b&gt; which is the default primary key type in Rails, but it didn’t. So I went back and changed the add column to &lt;b&gt;&lt;strong&gt;add_column :device_event_logs, :door_id, :bigint, null: true&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Finally I could add the foreign key, but it was very slow. I had a hunch that it was due to MariaDB checking if the key actually exists in the other table.&lt;br&gt;&lt;br&gt;To speed it up I disabled referential integrity by wrapping the SQL command in a &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/AbstractAdapter.html#method-i-disable_referential_integrity"&gt;&lt;b&gt;&lt;strong&gt;disable_referential_integrity&lt;/strong&gt;&lt;/b&gt;&amp;nbsp;block&lt;/a&gt; after which the foreign key was added in less than a second.&lt;br&gt;&lt;br&gt;The downside of disabling referential integrity is that a foreign key might point to something that doesn’t exist, but that wasn’t a problem in this migration since the column the foreign key was added to was completely empty.&lt;br&gt;&lt;br&gt;My migration looked like this in the end&lt;br&gt;&lt;br&gt;The production migration took 15 min, caused no locks or downtime, and increased the size of the database by a gigabyte.&lt;br&gt;&lt;br&gt;All in all, a big win and learning experience.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# `add_reference :device_event_logs, :door, foreign_key: true, null: true` is equuivalent to&lt;br&gt;add_column :device_event_logs, :door_id, :bigint, null: true&lt;br&gt;add_index :device_event_types, :door_id&lt;br&gt;add_foreign_key :device_event_types, :doors&lt;br&gt;&lt;br&gt;&lt;br&gt;execute(&lt;br&gt;  &amp;lt;&amp;lt;~SQL&lt;br&gt;    ALTER TABLE device_event_logs&lt;br&gt;    ADD CONSTRAINT fk_rails_53a1f7c81d&lt;br&gt;      FOREIGN KEY IF NOT EXISTS (door_id)&lt;br&gt;      REFERENCES doors (id),&lt;br&gt;      ALGORITHM = INPLACE ,&lt;br&gt;      LOCK = NONE;&lt;br&gt;  SQL&lt;br&gt;)&lt;br&gt;&lt;br&gt;&lt;br&gt;add_column :device_event_logs, :door_id, :bigint, null: true&lt;br&gt;&lt;br&gt;add_index :device_event_logs, :door_id, algorithm: :inplace&lt;br&gt;&lt;br&gt;# https://api.rubyonrails.org/classes/ActiveRecord/Migration.html#method-i-reversible&lt;br&gt;reversible do |dir|&lt;br&gt;  dir.up do&lt;br&gt;    disable_referential_integrity do&lt;br&gt;      execute(&lt;br&gt;        &amp;lt;&amp;lt;~SQL&lt;br&gt;          ALTER TABLE device_event_logs&lt;br&gt;          ADD CONSTRAINT fk_rails_53a1f7c81d&lt;br&gt;            FOREIGN KEY IF NOT EXISTS (door_id)&lt;br&gt;            REFERENCES doors (id),&lt;br&gt;            ALGORITHM = INPLACE,&lt;br&gt;            LOCK = NONE;&lt;br&gt;        SQL&lt;br&gt;      )&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;&lt;/pre&gt;
&lt;/div&gt;
</content>
    <summary type="html">At work, as part of a project I’m working on, I wanted to add a new reference to a table. ”Simple enough” - I thought. Spoiler alert, it wasn’t.

Adding a reference from one table to another is straight forward in Rails.

You create a migration using rails generate and then write in it something...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/18</id>
    <published>2022-09-30T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/having-a-monolith-is-a-single-point-of-failure-3cYdY3KZ7qHW"/>
    <title>“Having a monolith is a single point of failure”</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;I recently took part in a discussion that brought to light the most unusual argument against having a monolith that I have ever heard - that a monolith is a single point of failure.&lt;br&gt;&lt;br&gt;I want to make clear that I consider monoliths and microservices neither good nor bad, or universally better or worse at any particular job. To me these architectures are just tools that work better in some settings and worse in others. As I explained in &lt;a href="https://stanko.io/misguided-mark-misrepresents-micro-services-a-story-about-paradigms-XBMbcWf6RRkz"&gt;“Misguided Mark misrepresents Micro-services“&lt;/a&gt;, to me they are just paradigms - different ways to see and explain the same thing.&lt;br&gt;&lt;br&gt;The details of the discussion aren’t important for debunking the argument. The important thing is that the discussion was centered around should a new feature be added to a monolith or should it be a microservice.&lt;br&gt;&lt;br&gt;We covered quite a few common arguments for microservices all of which fell apart when examined. And then the other side brought up that having a monolith is a single point of failure.&lt;br&gt;&lt;br&gt;This argument shows a deep misunderstanding of what microservices are and how they work.&lt;br&gt;&lt;br&gt;People usually think about monoliths in terms of classes and methods (or modules and functions), but they think about microservices in terms of services and APIs.&lt;br&gt;&lt;br&gt;Services and APIs are the same thing as classes and methods.&lt;br&gt;&lt;br&gt;They are two sides of the same coin - on one side everything is in one program, on the other each class is its own program.&lt;br&gt;&lt;br&gt;In other words, they are two paradigms through which people build and think about applications. They are talking about the same thing just from different perspectives.&lt;br&gt;&lt;br&gt;If you have a monolith in which a call to the process method on the class Payment internally calls the authorize method on class User, then you have two places where something can go wrong. Either Payment#process can error or User#authorize can error. In both cases the call to Payment#process has erred.&lt;br&gt;&lt;br&gt;If you have microservices in which a request to the process_payment API on the Payment microservice internally makes a request to the authorize_token API on the User microservice, then you have two places where something can go wrong. Either the request to process_payment on the Payment service returns a 500 or the request to authorize_token on the User service returns a 500. In both cases the request to process_payment on the Payment microservice has erred.&lt;br&gt;&lt;br&gt;The number of failure points is the same.&lt;br&gt;&lt;br&gt;Though in reality, the microservices example has two more failure points because each request to a service can also fail due to various problems - like a misconfigured load balancer, or the machine running out of file descriptors, or a congested router in the data center, or any other reason a network request might fail…&lt;br&gt;&lt;br&gt;So where does this single point of failure argument come from?&lt;br&gt;&lt;br&gt;I believe that Netflix and a simple case of not thinking with your own head are the source of this odd argument.&lt;br&gt;&lt;br&gt;As most programmers know, Netflix is a big proponent of microservices. They have been making headlines across various sites popular in the IT industry how microservices have enabled them to weather any outage. They even made a tool called &lt;a href="https://github.com/netflix/chaosmonkey"&gt;Chaos Monkey&lt;/a&gt; that turns off services at random to test how resilient your system is.&lt;br&gt;&lt;br&gt;So if it works for Netflix why wouldn’t it work for us? Right?&lt;br&gt;&lt;br&gt;Let’s apply Chaos Monkey to our example with two services and see.&lt;br&gt;&lt;br&gt;If all services are on, payment processing works. But if one or both are turned off, payment processing fails. It can process payments only 25% of the time. A monolith would perform much better, it can either be on or off meaning that it can process payments 50% of the time.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3NT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--adde4441a0c6daecf02a090d948c0bbe2de482c1" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaE1CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c90f3e9d6e357f6174afa89a83bffa69811f1dd5/example_1.gif" filename="example_1.gif" filesize="56094" width="1024" height="559" previewable="true" caption="Animation that shows in which scenarios the system is up or down depending on which of the two services is on or off"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation that shows in which scenarios the system is up or down depending on which of the two services is on or off" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjc1LCJwdXIiOiJibG9iX2lkIn19--be2e4046683c754ac34d5ef16ce3853f0deb8d83/example_1.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation that shows in which scenarios the system is up or down depending on which of the two services is on or off
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;What gives? Maybe if we had two of every service it would be more resilient?&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3Nj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--968db05b682832325d7484708a70747b920b57cc" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaFFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--bc2e1b3be58f546e4fc83efd5741f6be9ccfc339/example_2.gif" filename="example_2.gif" filesize="282926" width="1024" height="559" previewable="true" caption="Animation that shows in which scenarios the system is up or down depending on which of the four services is on or off"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation that shows in which scenarios the system is up or down depending on which of the four services is on or off" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjc2LCJwdXIiOiJibG9iX2lkIn19--6444a4bfe14b5be8f03dbf37864b2dde13bffe7c/example_2.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation that shows in which scenarios the system is up or down depending on which of the four services is on or off
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;It would, but only 50% of the time. A monolith would perform much better, as it would fail to process the payment only when all instances are off which means it would process payments 75% of the time.&lt;br&gt;&lt;br&gt;The point of Chaos Monkey is to test how resilient your architecture is. It works with services because Netflix is invested in microservices and they have built a tool to test the resiliency of their architecture.&lt;br&gt;&lt;br&gt;But architecture is so much more than microservices or monoliths. It’s how flexible the system is, how easily it can be extended, how easily a team can maintain it, and how resilient it is to failure.&lt;br&gt;&lt;br&gt;The fact that Netflix has built a resilient architecture with microservices doesn’t mean that you can’t achieve the same with other architectures.&lt;br&gt;&lt;br&gt;Resilience to failure can be addressed both in microservices and in monoliths through the same patterns - &lt;a href="https://github.com/yammer/circuitbox"&gt;circuit breakers&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/Strategy_pattern"&gt;policies/strategies&lt;/a&gt;, and others.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">I recently took part in a discussion that brought to light the most unusual argument against having a monolith that I have ever heard - that a monolith is a single point of failure.

I want to make clear that I consider monoliths and microservices neither good nor bad, or universally better or...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/17</id>
    <published>2022-09-22T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/the-humble-activemodel-48C1eCmbgtrn"/>
    <title>The humble ActiveModel</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;ActiveModel is one of my most used tools in Rails applications. I use it in service objects, form objects and objects that represent external entities.&lt;br&gt;&lt;br&gt;Why? Because it provides a nice interface for validating inputs and results, it can have callbacks for pre and post-processing data, and it integrates well into various Rails conventions.&lt;br&gt;&lt;br&gt;A simple login form is my go to example for explaining how nice ActiveModel is to work with.&lt;br&gt;&lt;br&gt;I have seen people do login flows in controllers, in User models and in interactors. But no other approach is as straightforward as creating a Login model.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# /app/models/login.rb&lt;br&gt;class Login&lt;br&gt;  include ActiveModel::Model&lt;br&gt;  include ActiveModel::Validations::Callbacks&lt;br&gt;&lt;br&gt;  attr_accessor :username, :password&lt;br&gt;&lt;br&gt;  validates :username, presence: true&lt;br&gt;  validates :password, presence: true&lt;br&gt;  validate :validate_user_exists!&lt;br&gt;  validate :validate_password_for_user!&lt;br&gt;&lt;br&gt;  before_validation do&lt;br&gt;    @user = nil&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def validate_user_exists!&lt;br&gt;    return if user.present?&lt;br&gt;&lt;br&gt;    errors.add(:username, "not found")&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def validate_password_for_user!&lt;br&gt;    # `authenticate` comes from Rails&lt;br&gt;    # Reference:  https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password&lt;br&gt;    return if user&amp;amp;.authenticate(password)&lt;br&gt;&lt;br&gt;    errors.add(:password, "is invalid")&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def user&lt;br&gt;    @user ||= User.find_by(username: username)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Which can then be used in a controller like any other model:&lt;/p&gt;&lt;pre data-language="ruby"&gt;class LoginsController &amp;lt; ApplicationController&lt;br&gt;  def new&lt;br&gt;    @login = Login.new&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def create&lt;br&gt;    permitted_params = params.require(:login).permit(:username, :password)&lt;br&gt;    @login = Login.new(permitted_param)&lt;br&gt;&lt;br&gt;    if @login.valid?&lt;br&gt;      session[:current_user_id] = @login.user.id&lt;br&gt;      redirect_to root_path, notice: "Welcome back!"&lt;br&gt;    else&lt;br&gt;      render :new, status: :unprocessable_entity&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;You can use &lt;b&gt;&lt;strong&gt;form_for&lt;/strong&gt;&lt;/b&gt; without extra arguments or configuration, &lt;b&gt;&lt;strong&gt;I18n&lt;/strong&gt;&lt;/b&gt; works for the attributes and error messages just like it does for ActiveRecord models, and if the login fails the fields get repopulated.&lt;br&gt;&lt;br&gt;It looks and feels like you are working with an ActiveRecord model, which most of us know and love.&lt;br&gt;&lt;br&gt;When it comes to Service objects, the ActiveModel approach offers a way to communicate success and failure through validations. You can check the inputs with &lt;b&gt;&lt;strong&gt;valid?&lt;/strong&gt;&lt;/b&gt;, or you can check the result, and return a nice error message.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class Device::WarehouseReservator &amp;lt; ApplicationModel&lt;br&gt;  attr_accessor :device, :user&lt;br&gt;&lt;br&gt;  validates :device, presence: true&lt;br&gt;  validates :user, presence: true&lt;br&gt;&lt;br&gt;  after_initialize do&lt;br&gt;    self.user ||= device.creator&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def reserve!&lt;br&gt;    return false if invalid?&lt;br&gt;&lt;br&gt;    response = HTTP.post("http://device.inventory.com/api/v1/register", data: payload)&lt;br&gt;&lt;br&gt;    if response.ok?&lt;br&gt;      errors.add(:device, “failed to register”)&lt;br&gt;      return false&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    self.warehouse_reference = device.create_warehouse_reference(reservation_id: JSON.parse(response.body).fetch(:id))&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def payload&lt;br&gt;    { device_id: device.id, name: device.name, reservation_holder: user.name }&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;reservator = Device::WarehouseReservator.new(user: User.all.sample, device: Device.all.sample.reserver!&lt;br&gt;reservator.valid? # =&amp;gt; true&lt;br&gt;reservator.reserver!&lt;br&gt;Reservator.warehouse_reference.id # =&amp;gt; 1&lt;br&gt;&lt;br&gt;reservator = Device::WarehouseReservator.new(user: nil, device: nil)&lt;br&gt;reservator.valid? # =&amp;gt; false&lt;br&gt;reservator.reserver! # =&amp;gt; false&lt;br&gt;reservator.errors.full_messages # =&amp;gt; ["User missing", "Device missing"]&lt;/pre&gt;&lt;p&gt;This is a much nicer way to communicate what went wrong than raising and catching errors, returning symbols or custom Error objects.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;validate and coerce inputs by type through &lt;a href="https://api.rubyonrails.org/classes/ActiveModel/Attributes/ClassMethods.html"&gt;ActiveModel::Attributes&lt;/a&gt;&lt;/li&gt;&lt;li&gt;handle password storage and checking through &lt;a href="https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password"&gt;has_secure_password&lt;/a&gt;&lt;/li&gt;&lt;li&gt;add custom callbacks with &lt;a href="https://api.rubyonrails.org/classes/ActiveModel/Callbacks.html"&gt;ActiveModel::Callbacks&lt;/a&gt; (I usually implement an &lt;b&gt;&lt;strong&gt;after_initialize_callback&lt;/strong&gt;&lt;/b&gt; in &lt;b&gt;&lt;strong&gt;ApplicationModel&lt;/strong&gt;&lt;/b&gt;)&lt;/li&gt;&lt;li&gt;track changes to attributes using &lt;a href="https://api.rubyonrails.org/classes/ActiveModel/Dirty.html"&gt;ActiveModel::Dirty&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;ActiveModel is quite a versatile tool for that alone, but there is much more that it can do like:&lt;br&gt;&lt;br&gt;P.S. Many thanks to &lt;a href="https://shime.sh/"&gt;Hrvoje S.&lt;/a&gt; For reviwing this article.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">ActiveModel is one of my most used tools in Rails applications. I use it in service objects, form objects and objects that represent external entities.

Why? Because it provides a nice interface for validating inputs and results, it can have callbacks for pre and post-processing data, and it...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/16</id>
    <published>2022-08-27T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/take-a-break-4hwQBaUwEkZ9"/>
    <title>Take a break</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;At my first job I used to take a break every time I got stuck on a problem until one day my boss caught me looking out of the window.&lt;br&gt;&lt;br&gt;He didn’t say anything the first time he saw me, but when he saw me in the same position some 15 min later he yelled “At least sit by your desk and have your laptop open so that I think you are working instead of slacking off”.&lt;br&gt;&lt;br&gt;As I switched jobs, I learned that “at least make it look like you are working” is a prevalent philosophy in most offices. So I adapted and started scrolling through Reddit while on break to seem busy. But this didn’t work out as my boss had hoped for - I started taking more breaks because I couldn’t focus more often.&lt;br&gt;&lt;br&gt;Thankfully, I didn’t have to work in an office for the last 4 years so I was able to return to my old routine. But why was this method so much more effective than scrolling Reddit for 15min?&lt;br&gt;&lt;br&gt;Rachel and Stephen Kaplan developed a theory that might explain why - &lt;a href="https://en.wikipedia.org/wiki/Attention_restoration_theory"&gt;Attention Restoration Theory or ART for short&lt;/a&gt;. ART differentiates two kinds of attention humans can give - direct attention and effortless attention.&lt;br&gt;&lt;br&gt;We use direct attention when we focus on something - like trying to solve a problem, searching for something, following rules… When giving direct attention we consciously block out most distractions.&lt;br&gt;&lt;br&gt;Direct attention is a finite resource - if you use it too much you will run out of it and won’t be able to focus anymore - but it replenishes if it’s not used for a while. You can think of it as a muscle - if you use it too much it becomes sore and weak, but if you give it some rest it can work for much longer.&lt;br&gt;&lt;br&gt;When you run out of direct attention you start suffering from attention fatigue - a state in which you are easily irritable, tired and stressed out.&lt;br&gt;&lt;br&gt;In contrast, effortless attention is… well… effortless.&lt;br&gt;&lt;br&gt;The Kaplans tell us that nature is full of fascinating things to experience and give our effortless attention to - like the shapes of clouds, the wind on your skin, rustling of leaves, the sun breaking through the treetops, the chirps of birds, the sound of water rushing in a creek... So by looking at or being in nature we can replenish our direct attention because we will mostly use effortless attention to process it.&lt;br&gt;&lt;br&gt;The reason I took more breaks while scrolling Reddit was over use of direct attention. Reading through posts and commenting takes direct attention to do, therefore it doesn’t replenish during the break and I couldn’t focus as well when I started working again.&lt;br&gt;&lt;br&gt;But there is another compounding problem - &lt;a href="https://www.sciencedirect.com/science/article/abs/pii/S0749597809000399"&gt;attention residue&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Attention residue is an effect that prevents humans from switching their attention between tasks effortlessly. When you switch tasks your mind will still pay attention to the previous task for a while and prevent you from fully focusing on the new task.&lt;br&gt;&lt;br&gt;Whatever posts I read on Reddit during my break would linger in my mind and steal attention away from the problem I was trying to solve when I started working again. And when I took a break any problem I was working on before would still invade my thoughts and steal attention from what I was doing during my break.&lt;br&gt;&lt;br&gt;The problem with scrolling through Reddit while on break was that my attention was depleted before the break, it couldn’t replenish during the break, and then it was split between Reddit and work after the break.&lt;br&gt;&lt;br&gt;To be clear, Reddit isn’t the problem here - being unnecessarily busy is. I would have probably felt the same way if I read the news, a book, scrolled through Twitter, solved a Sudoku or played chess.&lt;br&gt;&lt;br&gt;Today, when I feel like I’m having a hard time focusing, I go out for a 15min walk if the weather is nice. If the weather doesn’t play along I make myself a cup of tea, look out of the window and enjoy the birds, the clouds passing by, the falling snow, or the sound of wind and rain.&lt;br&gt;&lt;br&gt;A proper 15 min break can keep you happy, focused and rested for hours.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3ND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--5aeca059d6a552f44001eec588f39913fb1dbb69" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaElCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4fcbb988ce8d018dd1cee23ce6ded3dc644749f5/image.jpeg" filename="image.jpeg" filesize="539434" width="1200" height="1600" previewable="true" caption="The sun shining through the tree tops in a forest"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The sun shining through the tree tops in a forest" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mjc0LCJwdXIiOiJibG9iX2lkIn19--6a7dc7b9c42d5bce95edce431b7bfcd20fe0b9cc/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/image.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The sun shining through the tree tops in a forest
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;P.S. Many thanks to Marko I. for reviewing drafts of this article.&lt;br&gt;&lt;br&gt;My inspiration for this article was a conversation I had with a co-worker who didn’t want to take a break when he was stressed out, after which I started encouraging all my co-workers to take a break when they start to feel attention fatigue. This is a cautionary tale for them.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">At my first job I used to take a break every time I got stuck on a problem until one day my boss caught me looking out of the window.

He didn’t say anything the first time he saw me, but when he saw me in the same position some 15 min later he yelled “At least sit by your desk and have your...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/15</id>
    <published>2022-08-21T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/keep-it-boring-dont-surprise-me-52opnwWR6CBh"/>
    <title>Keep it boring, don’t surprise me</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;blockquote&gt;I’ve spent a lot of my life worried that people will think I don’t know enough. Sometimes, that worry has made me use big words when I didn’t need to.&amp;nbsp;&lt;br&gt;&lt;br&gt;–Randall Munroe in the foreword to the Thing Explainer&lt;/blockquote&gt;&lt;div&gt;I used to be a stickler for organizing code by what it was. Models, decorators, form objects, view objects, value objects, service objects - all lived in their own separate directory. I felt that the code was well organized that way, that everybody knew what went where, and what the purpose of everything was just by how the code was organized.&lt;br&gt;&lt;br&gt;My obsession with grouping things that way made me blind to what I was actually doing - writing surprising code.&lt;br&gt;&lt;br&gt;When I think of the reasons why I did things that way only three things come to mind.&lt;br&gt;&lt;br&gt;First, I wanted to show off how much I knew about patterns, algorithms and programming. I wanted to be the best programmer, and prove that to my peers by organizing code in a manner that showed off my knowledge.&lt;br&gt;&lt;br&gt;Second, I didn’t know any better. The code was already organized into models, views and controllers so it was natural to me to continue this trend of having every kind of object separate.&lt;br&gt;&lt;br&gt;Third, I didn’t want to have “fat” models or controllers because that has bitten me in the past.&lt;br&gt;&lt;br&gt;Today I consider most things to be a model, with a few exceptions. Why? Because decorators, form objects, view objects, value objects, service objects, and others are just different ways to model data. Models aren’t just objects persisted in a database, a model is any object that represents a piece of data. Through them your app represents the world so that it can work with its data, similar to how &lt;a href="https://en.wikipedia.org/wiki/Mental_model"&gt;you have mental models about the world around you&lt;/a&gt; (e.g. what is night and what is day).&lt;br&gt;&lt;br&gt;Think about it. A model backed by a database - like User - just represents a row in that database that holds data about a person. It takes that row and wraps it into an object you can interact with - e.g. to get the person’s name or email.&lt;br&gt;&lt;br&gt;Decorators, form, value, service and view objects do the same. They take one or more pieces of data and give you an object to interact with that data in different ways.&lt;br&gt;&lt;br&gt;This might be a reductionist way of thinking because controllers and views do the same. But they are novel concepts that are used so often, and that are so important, that separating them out makes building the application easier.&lt;br&gt;&lt;br&gt;For instance, I work on an IoT application. One of the most common actions we do is to send messages to a devices. Because it is so novel and so common, we have extracted “device messengers” out of models into their own thing. That way developers don’t have to search for them and we communicate that they are an important part of the app.&lt;br&gt;&lt;br&gt;Treating most things as models, and making models heavier, has made the code I write much less surprising.&lt;br&gt;&lt;br&gt;Having super skinny objects produces surprising code because you have to be very verbose. Something boring like &lt;strong&gt;user.purchase(line_items).charge(authorization_code: params[:authorization_code])&lt;/strong&gt; becomes a convoluted mess like &lt;strong&gt;PurchaseChargerService(purchase: PurchaseBuilderService.new(line_items: line_items).call, form: purchase_form, user: current_user).call&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;Nobody expects things like &lt;strong&gt;PurchaseChargerService&lt;/strong&gt; and &lt;strong&gt;PurchaseBuilderService&lt;/strong&gt; to exist, yet alone that you have to call those in succession with a form object and the current user to make a purchase. While most people working on a web shop expect that a user can just purchase something, and that a purchase can be charged.&lt;br&gt;&lt;br&gt;By writing skinny objects you are pushing the complexity onto the programmer instead of having it in code.&lt;br&gt;&lt;br&gt;I’m not saying you should get rid of &lt;strong&gt;PurchaseChargerService&lt;/strong&gt; or &lt;strong&gt;PurchaseBuilderService&lt;/strong&gt; (ok, maybe the builder is pushing it a bit, I'd get rid of that one). I’m saying that you should make them as boring as possible.&lt;br&gt;&lt;br&gt;For all you know &lt;strong&gt;user.purchase&lt;/strong&gt; might call those same objects internally. You don’t have to know about that to make a purchase. It’s boring and it’s beautiful.&lt;br&gt;&lt;br&gt;How to make things boring?&lt;br&gt;&lt;br&gt;First, embrace the domain you are working with.&lt;br&gt;&lt;br&gt;Naming something a service and putting it in its own directory solves no problems, but it can create some. Naming something after what it does and putting it next to the thing it works with tells a story that can help people understand what’s going on.&lt;br&gt;&lt;br&gt;Instead of &lt;strong&gt;PurchaseChargerService&lt;/strong&gt; that lives in a service objects directory, try naming it &lt;strong&gt;Purchase::Charger&lt;/strong&gt; and putting it under the Purchase model (e.g. &lt;strong&gt;app/model/purchase/charger.rb&lt;/strong&gt;). You will know just by looking at the file that it has something to do with purchases and charging them.&lt;br&gt;&lt;br&gt;Second, write code that mimics how people think about the domain you are working in.&lt;br&gt;&lt;br&gt;Expose methods for common actions in your domain. If a &lt;strong&gt;User&lt;/strong&gt; can purchase line items, then add a &lt;strong&gt;purchase&lt;/strong&gt; method to the User model that accepts line items.&lt;br&gt;&lt;br&gt;Don’t push the complexity of figuring out how to make a purchase onto other people, solve it once in code, and explain it well so that others can build on top of your work. Don’t try to be too smart, most often people won’t care how the purchase is made so don’t try to expose it if it’s not needed.&lt;br&gt;&lt;br&gt;Third, extract actions into models to tell a story and form new domains.&lt;br&gt;&lt;br&gt;Don’t create new classes just to keep things DRY. If your model has many small or a single large method that deal with a single action, extract it into its own model.&lt;br&gt;&lt;br&gt;If the &lt;strong&gt;User&lt;/strong&gt; model has 5 methods related to purchasing or a single large &lt;strong&gt;purchase&lt;/strong&gt; method, extract that into a &lt;strong&gt;Purchase&lt;/strong&gt; model and delegate to it. If the &lt;strong&gt;Purchase&lt;/strong&gt; model has 10 methods that deal with charging credit cards, extract those into a &lt;strong&gt;Purchase::Charger&lt;/strong&gt; model and delegate to it.&lt;br&gt;&lt;br&gt;Nobody will think less of you for writing boring code.&lt;br&gt;&lt;br&gt;Nobody will fuss about a method, module or class that is understandable but is too pedestrian.&lt;br&gt;&lt;br&gt;Nobody wants to work with code that requires a manual to figure out how to do the most basic actions.&lt;br&gt;&lt;br&gt;Keep it simple, keep it boring, and don’t surprise other.&lt;br&gt;&lt;br&gt;&lt;strong&gt;&lt;em&gt;Update on 2022-08-27&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;After sharing this article with a few friends I got a wonderful question which I want to address.&lt;br&gt;&lt;br&gt;&lt;strong&gt;Q:&lt;/strong&gt; If you put everything in &lt;strong&gt;app/models&lt;/strong&gt; isn’t that surprising too? People will expect an ActiveRecord-like object and they can get anything.&lt;br&gt;&lt;br&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes that would be surprising, but I don’t see a situation where someone would write &lt;strong&gt;Purchase::Charger.new(purchase: Purchase.all.sample).save&lt;/strong&gt; and hit Enter out of the blue.&lt;br&gt;&lt;br&gt;If you see &lt;strong&gt;Purchase::Charger&lt;/strong&gt; you’d at least be curious why the model name isn’t a noun - the name implies that it’s different.&lt;br&gt;&lt;br&gt;Why would someone create and try to save an object they know nothing about? That implies that they are just passing random arguments to random methods expecting things to just work.&lt;br&gt;&lt;br&gt;I don't see this as a realistic scenario, I expect people to know what they are doing and if they don't to read up until they do. In other words, I trust them to do good work.&lt;br&gt;&lt;br&gt;But I agree that there are cases where it’s beneficial to have a similar interface for some objects. Therefore, I make most objects in &lt;strong&gt;app/models&lt;/strong&gt; inherit from &lt;strong&gt;ActiveModel::Model&lt;/strong&gt;. There are many reasons for this which I think I’ll cover in a separate article.&lt;br&gt;&lt;br&gt;P.S. Many thanks to &lt;a href="https://shime.sh/"&gt;Hrvoje S.&lt;/a&gt; for reviewing drafts of this article.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">“I’ve spent a lot of my life worried that people will think I don’t know enough. Sometimes, that worry has made me use big words when I didn’t need to. 

–Randall Munroe in the foreword to the Thing Explainer”

I used to be a stickler for organizing code by what it was. Models, decorators, form...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/14</id>
    <published>2022-07-15T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/scrum-is-for-time-estimates-not-projects-23ULmuBVSMqo"/>
    <title>Scrum is for time estimates, not projects</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;From my experience Scrum is the prevalent project management framework in software development. Most of the teams I was part of used it to develop, deliver and maintain their projects. Despite its prevalence I always felt like Scrum was bogs me down - that it was more harmful than useful.&lt;br&gt;&lt;br&gt;I knew what was bothering me the most - the daily standup. But even in teams that had a standup once a week I felt like Scrum didn’t make the project better. Yet we were burning two work-hours per person every week on its ritual.&lt;br&gt;&lt;br&gt;One day while reading about Control Theory and PID controllers, I realized that Scrum was a time estimation tool.&lt;br&gt;&lt;br&gt;To understand this, you first have to know what a PID controller is and how it works. In a nutshell, it’s a device that observes a dimension of a process - like the speed of a wheel - and tries to keep it at a desired value.&lt;br&gt;&lt;br&gt;If the wheel goes too slow the controller speeds it up, if it goes too fast the controller slows it down. The magic of PID controllers is that they, once tuned, don’t over or undershoot the desired value. Their magic consists of 3 steps - P, I &amp;amp; D.&lt;br&gt;&lt;br&gt;P, the proportional, corrects the difference between the desired and actual value. I, the integral, looks at past over or undershoots and corrects for them. D, the derivative, nudges the result closer to the ideal value.&lt;br&gt;&lt;br&gt;That is exactly what Scrum does. Standups correct problems as they occur. Planning corrects over or undershoots in the committed amount of work. Retros nudge the process towards the ideal outcome.&lt;br&gt;&lt;br&gt;If Scrum is a PID controller what dimension does it control?&lt;br&gt;&lt;br&gt;The only one that has a fixed value - the duration of the sprint. All other values are in flux - the number of story points, the number of tasks in a sprint, the number of people in a team.&lt;br&gt;&lt;br&gt;By controlling the duration of a sprint you can estimate the duration of a project. If you chop up a project into 30 tasks, and the team solves about 8 tasks every sprint, you can be somewhat certain that the project will take 4 to 6 sprints. If a sprint is 2 weeks long, then the project will take 12 weeks.&lt;br&gt;&lt;br&gt;This chopping up of a project into tasks is why Scrum bogs me down - it kills the vision. Without vision you can’t know what you are building and therefore you can’t utilize your expertise to make it better.&lt;br&gt;&lt;br&gt;Building a project task by task can produce something that doesn’t work as a whole. Each task makes sense on its own but all of them together don’t form a whole - they don’t accomplish the goal of the project.&lt;br&gt;&lt;br&gt;Because tasks don’t carry a sense of how they fit together, any sense of wholeness is accidental. One feature can be split into multiple tasks which can produce a different result depending on which one is implemented first. New tasks can be added with no regard as to how they incorporate into the vision, which can produce nonsensical features. When a feature is broken into multiple tasks, changing one task without updating the others leads to a disjointed feature.&lt;br&gt;&lt;br&gt;The worst possible case is when someone creates tasks that build on top of each other. Working on such tasks is like being led by a carrot on a stick through what someone else thinks is the best way to build the project. Instead of letting you, the expert at building software, decide what the best way to build it is. This is frustrating and demotivating, and you can’t prevent it because you can’t see the bigger picture.&lt;br&gt;&lt;br&gt;Scrum isn’t a framework for people building the project, it’s a framework for people concerned with timelines and roadmaps.&lt;br&gt;&lt;br&gt;Builders are concerned with how things are supposed to work, how they connect together and interact with one another. They are concerned with what they are building, with the vision.&lt;br&gt;&lt;br&gt;Scrum has no mechanism to help you build your project. None of its rituals can help you shape a project. They can’t help you form a vision for what you are about to build, or help you figure out what is still undefined. Scrum assumes that this is already figured out when you start grooming and planning the project. The best you can ever do is say “I have no clue what we are building” and throw a sprint out.&lt;br&gt;&lt;br&gt;Yet the burden of making Scrum work falls on the ones it doesn’t help - the builders - the ones concerned with building something great. It only helps them adhere to a timeline. But a bad project delivered on time is still a bad project.&lt;br&gt;&lt;br&gt;&lt;em&gt;P.S. Many thanks to &lt;/em&gt;&lt;a href="https://shime.sh/"&gt;&lt;em&gt;Hrvoje S.&lt;/em&gt;&lt;/a&gt;&lt;em&gt; for reviewing drafts of this article.&lt;br&gt;&lt;br&gt;If you are looking for a framework that does help you shape your project and build something better, but has a looser concept of timelines, take a look at &lt;/em&gt;&lt;a href="https://basecamp.com/shapeup/webbook"&gt;&lt;em&gt;Shape Up&lt;/em&gt;&lt;/a&gt;&lt;em&gt;. I recommend that book even if you aren't looking for a new framework, it describes excellent tools to help you identify if your project is well defined - like breadboards and fat marker sketches.&lt;/em&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">From my experience Scrum is the prevalent project management framework in software development. Most of the teams I was part of used it to develop, deliver and maintain their projects. Despite its prevalence I always felt like Scrum was bogs me down - that it was more harmful than useful.

I knew...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/13</id>
    <published>2022-07-11T12:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/misguided-mark-misrepresents-micro-services-a-story-about-paradigms-XBMbcWf6RRkz"/>
    <title>Misguided Mark misrepresents Micro-services: A story about paradigms</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Tom was brewing tea in preparation for a 5 o'clock meeting he knew little about. The agenda was so vague it might as well have been left out - it read “Mark introduces a new system for controlling 3rd party IoT water sprinklers”. That was standard practice for CorpCorp - the company Tom worked for - everything was a meeting and things were only ever presented through meetings with at least 10 attendees, even when an email would be more than enough.&lt;br&gt;&lt;br&gt;But Tom wanted to attend this meeting. He was the head of the Web development department and 3rd-party integrations were his responsibility. His team was short-staffed to the point that they couldn’t start working on this integration until the next quarter.&lt;br&gt;&lt;br&gt;When Mark, a developer from the in-house IoT sprinkler department, offered to develop this integration Tom immediately accepted - Mark used to work as a Web developer before he joined CorpCorp and Tom thought that maybe he wanted to switch departments.&lt;br&gt;&lt;br&gt;5 o’clock rolls around, Tom joins the meeting from his home office and takes a big sip of tea.&lt;br&gt;&lt;br&gt;On his screen is Mark, standing in front of a nice looking slide deck that in bold letters says “Controlling 3rd party water sprinklers: The future of CorpCorp”.&lt;br&gt;&lt;br&gt;Mark greeted everybody and started to present. He explained that he had spent two weeks planning out this integration and came to the conclusion that it will be built using micro-services - 13 of them - even though most of CorpCorp’s Web services were in a monolithic Web app.&lt;br&gt;&lt;br&gt;Mark explained that monoliths were a thing of the past - they didn’t scale, and always ended up too complex to maintain. Micro-services, on the other hand, scaled infinitely and were super easy to maintain because having everything separate improved encapsulation and separation of concerns.&lt;br&gt;&lt;br&gt;Mark stopped, looked at all the confused faces, and asked if there were any questions. Immediately a few managers that didn’t listen to half of the presentation asked simple questions that Mark already explained during his presentation - this was also standard practice at CorpCorp.&lt;br&gt;&lt;br&gt;Tom had a working theory that the managers get bored by the technical jibber-jabber and doze off, but when the meeting ends they want to prove that they actually listened and that they were a useful addition to the meeting, so they ask a question about the last thing that they remember. Further experiments and observations were required to prove this theory, but even knowing if that was the case wouldn’t solve the problem - that the meeting should have been an email.&lt;br&gt;&lt;br&gt;Once the flurry of questions ended Tom started to talk. He thanked Mark for taking the time to research this integration, and confirmed that it would be a good solution to the problem, but that he shouldn’t move ahead with its implementation because the system would cause too many problems down the line. He then added that Mark couldn’t see how misguided he was about monoliths because he clinged too hard to his paradigm to think level-headed.&lt;br&gt;&lt;br&gt;The managers woke up out of a sudden - they didn’t want to miss the drama.&lt;br&gt;&lt;br&gt;Tom started to address each point of Mark’s presentation. He began by asking Mark ih he knew why CorpCorp developed a Monolith in the first place. Mark just shrugged and guessed that it was because they were at the peak of their popularity when the project was started. Tom refuted that and said that a monolith was conscious decision by the department because:&lt;br&gt;&lt;br&gt;&lt;strong&gt;MONOLITHS ARE EASIER TO MAINTAIN FOR SMALLER TEAMS&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Tom’s department counted 12 people, but those 12 people were enough to handle most of CorpCorp’s business. Only in recent months, after one employee retired and another quit, did Tom’s department start to struggle with meeting business demands.&lt;br&gt;&lt;br&gt;Tom’s employees built a monolith and decided upon a convention for going about their most common tasks. This allowed them to be flexible - anyone could work on any part of the system because they knew how everything was supposed to be organized. And over time, as features were added or removed, and bugs fixed, everybody became familiar with most of the system. So anybody was able to add a feature or fix a bug anywhere in the system.&lt;br&gt;&lt;br&gt;You could achieve the same by implementing a convention on top of micro-services, but there is nothing stopping you from implementing multiple conventions. The more people that work on your micro-services, the more opinions there are on how they should be implemented, the likelier the chance that convention will be broken or changed.&lt;br&gt;&lt;br&gt;In fact that is a big selling point of micro-services - you can use any technology and any convention you want for each individual service. And that is a desirable trait for mammoth companies that suck up developers just so the competition won't get them - they can keep them happy by letting them do what they want in their own micro-cosmos. This sometimes works out and sometimes it doesn’t, but for these kinds of companies that is just part of doing business.&lt;br&gt;&lt;br&gt;In general, it is better for individuals and small teams, to be intentional with what they do and how they go about it. Especially in software development - where the question isn’t “is it doable?”, because the answer is always “yes”, but “how much time will it take?”.&lt;br&gt;&lt;br&gt;And a monolith is great at ensuring that - one vision, one codebase and everybody is aligned.&lt;br&gt;&lt;br&gt;&lt;strong&gt;MONOLITHS ARE EASIER TO SCALE, BUT THEY DON’T SCALE AS WELL AS MICRO-SERVICES&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Tom decided to use an analogy here so that the managers wouldn’t doze off again. A monolith is like a hamburger, it is a meal consisting of a bun, a patty and some salad. While micro-services are a patty, some bread and a salad as three separate meals.&lt;br&gt;&lt;br&gt;With a monolith, if you want to double the throughput you double the number of running instances. This is the equivalent of ordering two hamburgers when you are very hungry. The good thing is that it’s simple - you just double everything and you are done, the bad part is that it can be wasteful.&lt;br&gt;&lt;br&gt;In micro-services, if you want to double the throughput you first have to identify what the bottleneck is, and then you double that service and some of its upstream services. This is the equivalent of ordering two patties, a regular salad and a bun when you are very hungry - you only pay for what you really want.&lt;br&gt;&lt;br&gt;But figuring out what the bottleneck is can be hard, both in monoliths and micro-services.&lt;br&gt;&lt;br&gt;&lt;strong&gt;COMPLEXITY COMES FROM BUSINESS NOT CODE&lt;/strong&gt;&lt;br&gt;&lt;br&gt;The notion that monoliths are complex to maintain and micro-services aren’t is a myth. In a monolith and in micro-services complexity comes from the business domain - what the app does. If the business domain is complex or not thought out - if there are no clear rules, if the process is dependent on many factors, if the process has many interdependent steps, if the process is convoluted to humans - the code will reflect that.&lt;br&gt;&lt;br&gt;The code you write mirrors how you think about a problem. If you don’t think clearly, or if the problem is inherently muddy, then you are out of luck - architecture has little to do with that.&lt;br&gt;&lt;br&gt;&lt;strong&gt;MICRO-SERVICES IMPROVING ENCAPSULATION OR SEPARATION OF CONCERNS IS A MYTH&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Tom had to resort to another analogy here to keep the managers listening. Encapsulation is the act of bundling together things that belong together. It is in a way similar to doing laundry - imagine you have two laundry baskets: one for colored clothes and another for white &amp;amp; black clothes.&lt;br&gt;&lt;br&gt;Separating the clothes in their baskets is encapsulation - each basket encapsulates only its set of clothes. But where would you put dark blue underwear?&lt;br&gt;&lt;br&gt;On the one hand it is blue - a color - so it should go into the color basket. But on the other hand it is dark blue, which is close to black - so it should go into the white &amp;amp; black basket. Depending on who you ask you will get a different answer.&lt;br&gt;&lt;br&gt;But people who argue that micro-services improve encapsulation &amp;amp; separation of concerns argue that, somehow, the problem will solve itself if you put the laundry baskets really far apart.&lt;br&gt;&lt;br&gt;If you are bad at separating concerns or encapsulating code, you will be equally as bad at it in a monolith and in micro-services.&lt;br&gt;&lt;br&gt;Tom stopped for a moment to look at everyone’s faces, the managers were more alert than they have ever been while Mark was embarrassed. Tom felt bad, and decided to give a moral to this story to lighten the mood.&lt;br&gt;&lt;br&gt;He said that this was a common mistake that young developers make - they get too attached to their paradigm and become unable to understand why someone would do things differently. They think that the existence of a new paradigm - a new school of thought - must invalidate the old one. Why else would then the new school exist? Why would micro-services exist if monoliths were good?&lt;br&gt;&lt;br&gt;People form an understanding of the world through paradigms. There is no one perfect paradigm, no one ultimate school of thought, just like there is no one perfect theory of how the world works..&lt;br&gt;&lt;br&gt;Look at Newton’s and Einstein's model of gravity. Einstein’s model is superior to Newton’s - it is better at explaining how objects will move when they are going slow and when they are going close to the speed of light.&lt;br&gt;&lt;br&gt;This precision comes at a cost - it is much harder to compute Einstein’s gravity equations than Newton’s. But is this computational cost worth it? When the object is moving slowly, Newton’s equations are a fraction of a percent off compared to Einstein’s equations.&lt;br&gt;&lt;br&gt;Many civil engineers say the trade off isn’t worth it. For their day to day work they use Newton’s equations because they can iterate bridge, road, and building designs quickly and find the best solution. But NASA engineers would beg to differ - a precise result is much more important to them.&lt;br&gt;&lt;br&gt;Each paradigm is good at something and bad at something else. It is up to you to understand the trade off and pick what works best for you.&lt;br&gt;&lt;br&gt;P.S. The style of writing in this essay was inspired by a book I recently read - &lt;a href="https://geraldmweinberg.com/Site/AYLO.html"&gt;Are your light on?&lt;/a&gt; by Donald C. Gause and Gerald M. Weinberg. Its a fantastic book about problem solving and thinking about problems.&lt;br&gt;&lt;br&gt;Many thanks to Marko I. and &lt;a href="https://shime.sh/"&gt;Hrvoje S.&lt;/a&gt; for reading drafts of this article and providing feedback&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Tom was brewing tea in preparation for a 5 o'clock meeting he knew little about. The agenda was so vague it might as well have been left out - it read “Mark introduces a new system for controlling 3rd party IoT water sprinklers”. That was standard practice for CorpCorp - the company Tom worked...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/12</id>
    <published>2022-07-01T12:00:00Z</published>
    <updated>2026-03-09T08:17:30Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs"/>
    <title>How I stumbled upon Strada while forwarding an email</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;I wanted to forward an email one evening, so I opened up the Hey app on my phone, found the email, tapped on the “More” button, and just before I hit “Forward” I noticed a “Share or print…” button at the bottom of the screen. I hit “Share” and to my surprise was greeted by a share sheet. It offered me to send a link to the email via SMS, Slack, Signal, and others.&lt;br&gt;&lt;br&gt;If it was any other app this wouldn’t surprise me, but Hey is a hybrid app — its views are server-rendered HTML and some JavaScript wrapped in an app that is essentially a web browser. Showing a share sheet isn’t something you can do with HTML and JS, so how did the team at Hey do this?&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a3195539072eb92ebd807203254bc62fb37ecb12" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZW89IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--45aa0edae6219c93988dfdcdef399377df617ddd/strada_demo.gif" filename="strada_demo.gif" filesize="3277647" width="450" height="800" previewable="true" presentation="gallery" caption="Opening a share sheet in Hey"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Opening a share sheet in Hey" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM0LCJwdXIiOiJibG9iX2lkIn19--19f6b4915cb0c6f60ef109bb06dbf2c8651f8b51/strada_demo.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Opening a share sheet in Hey
  &lt;/figcaption&gt;
&lt;/figure&gt;I assumed that the Hey mobile app used the same HTML and JS as the web version. So I opened the Hey web app on my computer and started poking around.&lt;br&gt;&lt;br&gt;The first thing I noticed was that all the buttons that were visible in the mobile app were present in the web version. But they were hidden unless the frontend was running in a browser that used a special User-Agent header. So I changed my User-Agent and reloaded the page. The UI changed — the header row was gone, some buttons looked like they were disabled and new buttons showed up.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzNT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--4a180dcb8bbaa47312856609442421d0108a9b5e" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZXM9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--a6c02144c2085d9affda8abb4b6ebc146de67632/2022-06-13_15-11_3.png" filename="2022-06-13_15-11_3.png" filesize="244096" width="852" height="1010" previewable="true" presentation="gallery" caption="Left: Hey web app in Firefox with the default User-Agent. Right: Impersonating Hey iOS app by changing the User-Agent"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Left: Hey web app in Firefox with the default User-Agent. Right: Impersonating Hey iOS app by changing the User-Agent" loading="lazy" style="aspect-ratio:852 / 1010;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM1LCJwdXIiOiJibG9iX2lkIn19--70aa93da264e080b3e709a1833e193027159ac67/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/2022-06-13_15-11_3.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Left: Hey web app in Firefox with the default User-Agent. Right: Impersonating Hey iOS app by changing the User-Agent
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;I thought the layout changes were related to Turbo Native and the disabled buttons seemed like a CSS issue, so l focused on the “Share and print…” button.&lt;br&gt;&lt;br&gt;Inspecting it showed that it was controlled by a &lt;b&gt;&lt;strong&gt;bridge/share&lt;/strong&gt;&lt;/b&gt; Stimulus controller. So I opened that controller and saw that it only had a &lt;b&gt;&lt;strong&gt;component&lt;/strong&gt;&lt;/b&gt; static variable and a &lt;b&gt;&lt;strong&gt;#share&lt;/strong&gt;&lt;/b&gt; method. The static variable was hard-coded to the value “share”. The &lt;b&gt;&lt;strong&gt;#share&lt;/strong&gt;&lt;/b&gt; method just passed the current URL to a &lt;b&gt;&lt;strong&gt;#send&lt;/strong&gt;&lt;/b&gt; method it inherited from &lt;b&gt;&lt;strong&gt;bridge/base_component_controller&lt;/strong&gt;&lt;/b&gt;.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="javascript"&gt;// bridge/share.js&lt;br&gt;&lt;br&gt;import BaseComponentController from "bridge/base_component_controller"&lt;br&gt;&lt;br&gt;class ShareController extends BaseComponentController {&lt;br&gt;    static component = "share"&lt;br&gt;&lt;br&gt;    share(event) {&lt;br&gt;        event.preventDefalt()&lt;br&gt;        this.send("share", { url: window.location })&lt;br&gt;    }&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;I followed the code and opened the &lt;b&gt;&lt;strong&gt;bridge/base_component_controller&lt;/strong&gt;&lt;/b&gt;. Its &lt;b&gt;&lt;strong&gt;#send&lt;/strong&gt;&lt;/b&gt; method took an event name, a data object, and a callback function. It built a &lt;b&gt;&lt;strong&gt;message&lt;/strong&gt;&lt;/b&gt; object with the event name, data, callback and the controller’s component. Then it forwarded everything to the &lt;b&gt;&lt;strong&gt;window.webBridge.send&lt;/strong&gt;&lt;/b&gt; method and stored the returned value as the ID of the message.&lt;/p&gt;&lt;pre data-language="javascript"&gt;// bridge/base_component_controller.js&lt;br&gt;// Pseudocode for BaseComponentController's send method&lt;br&gt;send(event, data, callback) {&lt;br&gt;    let message = { component: this.constructor.component, event, data, callback }&lt;br&gt;  let messageId = window.webBridge.send(message)&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;But where did &lt;b&gt;&lt;strong&gt;window.webBridge&lt;/strong&gt;&lt;/b&gt; come from? I searched all the files and found it was initialized right after a class named “Strata” was loaded. From the name of the class I knew I was looking at Strada — the, yet unreleased, framework from Hotwired.&lt;br&gt;&lt;br&gt;This got me excited! I started reading the code of the class to figure out what it does.&lt;/p&gt;&lt;pre data-language="javascript"&gt;// strata.js&lt;br&gt;// Pseudocode for Strata's send method&lt;br&gt;send(originalMessage) {&lt;br&gt;    if (!this.supportsComponent(message.component)) return null&lt;br&gt;    let id = this.generateID()&lt;br&gt;    let message = {&lt;br&gt;        id: id,&lt;br&gt;        component: originalMessage.component,&lt;br&gt;        event: originalMessage.event,&lt;br&gt;        data: originalMessage.data || {}&lt;br&gt;    }&lt;br&gt;    this.adapter.receive(message)&lt;br&gt;    if (originalMessage.callback) this.callbacks[id] = originalMessage.callback&lt;br&gt;    return id&lt;br&gt;}&lt;br&gt;&lt;br&gt;receive(message) {&lt;br&gt;    const callback = this.callbacks[message.id]&lt;br&gt;    if (callback) callback(message.data)&lt;br&gt;}&lt;br&gt;&lt;br&gt;supportsComponent(component) {&lt;br&gt;    return this.adapter.supportsComponent(component)&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;On load, it initialized itself and was assigned to &lt;b&gt;&lt;strong&gt;window.webBridge&lt;/strong&gt;&lt;/b&gt;. Then it fired a &lt;b&gt;&lt;strong&gt;web-bridge:ready&lt;/strong&gt;&lt;/b&gt; on the document.&lt;br&gt;&lt;br&gt;It had an &lt;b&gt;&lt;strong&gt;adapter&lt;/strong&gt;&lt;/b&gt; variable which was called in most of its methods.&lt;br&gt;&lt;br&gt;Its &lt;b&gt;&lt;strong&gt;#send&lt;/strong&gt;&lt;/b&gt; method generated an ID for each message, then it appended the ID to the message, stored the callback and forwarded the rest to the adapter.&lt;br&gt;&lt;br&gt;There was also a &lt;b&gt;&lt;strong&gt;#receive&lt;/strong&gt;&lt;/b&gt; method which seemed to take a message object and execute any callback method associated with its ID. That explained why the callbacks were stored when a message was sent.&lt;br&gt;&lt;br&gt;Then there was the &lt;b&gt;&lt;strong&gt;spportsComponent&lt;/strong&gt;&lt;/b&gt; method that queried the adapter for components it could support. Probably to avoid sending messages that the adapter couldn't process.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Now I started looking for the adapter implementation but I couldn’t find it. This was where the code ended.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;And then it hit me! This would usually run within a browser view controlled by the native app. The app can inject JavaScript into it. That’s why I couldn’t find the adapter implementation — it was in the native app.&lt;br&gt;&lt;br&gt;But why would the app inject the adapter into the frontend? This didn’t make sense at first. Then I remembered that in browsers controlled by a native app there is a special &lt;b&gt;&lt;strong&gt;window.external&lt;/strong&gt;&lt;/b&gt; object (on iOS it’s &lt;b&gt;&lt;strong&gt;window.webkit&lt;/strong&gt;&lt;/b&gt;). The app can expose functions on that object that when called from JS invoke a handler function in the app.&lt;br&gt;&lt;br&gt;So, the app probably registered an adapter by injecting JS into the browser. That would explain why &lt;b&gt;&lt;strong&gt;web-bridge:ready&lt;/strong&gt;&lt;/b&gt; fires when Strada loads — so that the app knows when it can register the adapter. And through that adapter, the frontend can send messages to the app.&lt;/p&gt;&lt;pre data-language="swift"&gt;// AppDelegate.swift&lt;br&gt;// Example of how you could register an adapter with Strada from iOS&lt;br&gt;let supportedComponents = ["share"]&lt;br&gt;&lt;br&gt;let supportedComponentsJSON = String(&lt;br&gt;    data: JSONSerialization.data(&lt;br&gt;        withJSONObject: supportedComponents,&lt;br&gt;        options: []&lt;br&gt;    ),&lt;br&gt;    encoding: String.Encoding.utf8&lt;br&gt;)&lt;br&gt;&lt;br&gt;let registerAdapterJS = """&lt;br&gt;window.registerStradaAdapter = () =&amp;gt; {&lt;br&gt;  const supportedComponents = \(supportedComponentsJSON)&lt;br&gt;&lt;br&gt;  window.webBridge.setAdapter({&lt;br&gt;        platform: "ios",&lt;br&gt;        supportsComponent: (name) =&amp;gt; return supportedComponents.include(name),&lt;br&gt;    supportedComponents: supportedComponents,&lt;br&gt;        receive: (message) =&amp;gt; window.webkit.messageHandlers.strada.receive(message))&lt;br&gt;    })&lt;br&gt;}&lt;br&gt;&lt;br&gt;if (window.webBridge) {&lt;br&gt;  window.registerStradaAdapter()&lt;br&gt;}&lt;br&gt;else {&lt;br&gt;  document.addEventListener("web-bridge:ready", window.registerStradaAdapter)&lt;br&gt;}&lt;br&gt;"""&lt;br&gt;&lt;br&gt;// Inject the script right after the document is loaded&lt;br&gt;webView.configuration.userContentController.addUserScript(&lt;br&gt;    WKUserScript(&lt;br&gt;      source: registerAdapterJS,&lt;br&gt;    injectionTime: WKUserScriptInjectionTime.AtDocumentEnd,&lt;br&gt;    forMainFrameOnly: true&lt;br&gt;  )&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;And the app could send messages back to the frontend by injecting them into the browser like JavaScript.&lt;/p&gt;&lt;pre data-language="swift"&gt;// AppDelegate.swift&lt;br&gt;&lt;br&gt;// Example of how an iOS app could add a&lt;br&gt;// `window.webkit.messageHandlers.strada.receive`&lt;br&gt;// function and process calls to it.&lt;br&gt;&lt;br&gt;// Somewhere in AppDelegate after you have created your&lt;br&gt;// browser view (webView) and have initialized Turbo Native&lt;br&gt;&lt;br&gt;let strada = Strada()&lt;br&gt;let contentController = webView.configuration.userContentController&lt;br&gt;contentController.addScriptMessageHandler(strada, name: "strada")&lt;br&gt;&lt;br&gt;// Strada.swift&lt;br&gt;&lt;br&gt;// Example message handler that responds to calls to&lt;br&gt;// `window.webkit.messageHandlers.strada.receive`&lt;br&gt;&lt;br&gt;class Strada: WKScriptMessageHandler {&lt;br&gt;    func userContentController(userContentController: WKUserContentController!,&lt;br&gt;                                                         didReceiveScriptMessage message: WKScriptMessage!) {&lt;br&gt;        // The `name` attribute holds the name of the function that was invoked&lt;br&gt;      if (message.name != "receive") {&lt;br&gt;        return&lt;br&gt;    }&lt;br&gt;&lt;br&gt;    if (message.body["component"] == "share" &amp;amp;&amp;amp;&lt;br&gt;        message.body["event"] == "share") {&lt;br&gt;      // show share sheet&lt;br&gt;    }&lt;br&gt;    }&lt;br&gt;}&lt;br&gt;&lt;br&gt;// Strada.swift&lt;br&gt;&lt;br&gt;// Example of how you could call the adapter from iOS&lt;br&gt;let message: [String: Any] = [&lt;br&gt;  "id": "7",&lt;br&gt;  "data": ["foo": "bar"]&lt;br&gt;]&lt;br&gt;let messageJSON = String(&lt;br&gt;    data: JSONSerialization.data(&lt;br&gt;        withJSONObject: message,&lt;br&gt;        options: []&lt;br&gt;    ),&lt;br&gt;    encoding: String.Encoding.utf8&lt;br&gt;)&lt;br&gt;&lt;br&gt;webView.evaluateJavaScript(&lt;br&gt;    "window.webBridge.receive(\(messageJSON))",&lt;br&gt;    completionHandler: nil&lt;br&gt;)&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;I realized that Strada was a bridge between the native app and the frontend.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;And through that bridge one can patch in functions that the platform supports but the browser doesn’t — things like showing a share sheet.&lt;br&gt;&lt;br&gt;The mystery of the share sheet was solved. But now my head was buzzing with possibilities.&lt;br&gt;&lt;br&gt;Most native apps are functionally the same as, or a subset of, their web counterpart. Without Strada, a native app would have to re-implement some of the views and logic of its web counterpart. This means that a team would have to build the same app at least twice — once for the web and another time for iOS, Android, Mac, or Windows — just so they could use some platform-specific features. This is even more wasteful if we take into account that most web apps are already optimized to work on phones, tablets, and PCs.&lt;br&gt;&lt;br&gt;But with Strada, any existing web app can become a native app.&lt;br&gt;&lt;br&gt;Many thanks to Karla, Marko, Hrvoje &amp;amp; Vlado for reviewing drafts of this article.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">I wanted to forward an email one evening, so I opened up the Hey app on my phone, found the email, tapped on the “More” button, and just before I hit “Forward” I noticed a “Share or print…” button at the bottom of the screen. I hit “Share” and to my surprise was greeted by a share sheet. It...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/13</id>
    <published>2022-06-15T12:00:00Z</published>
    <updated>2024-03-22T14:17:58Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/hotwired-postKGrYxj1F"/>
    <title>Hotwired</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;This talk will be an introduction to Hotwired, a bundle of three frameworks that enable server-side rendered pages to act and feel as single page applications.&lt;br&gt;&amp;nbsp;The frameworks are:&lt;/div&gt;&lt;ul&gt;&lt;li&gt;&amp;nbsp;Turbo - a successor to TurboLinks but with expanded functionality through the addition of turbo frames and turbo streams&lt;/li&gt;&lt;li&gt;Stimulus - a component framework that turns pre-rendered HTML into JS components&lt;/li&gt;&lt;li&gt;Strada - his part of the talk is reverse engineered out of Hey's source code; Strada is a framework/communication protocol that enables the frontend JS to interact with native host functionality&lt;/li&gt;&lt;/ul&gt;&lt;div&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2022-06-15/rubyzg_hotwired.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">This talk will be an introduction to Hotwired, a bundle of three frameworks that enable server-side rendered pages to act and feel as single page applications.
 The frameworks are:
•  Turbo - a successor to TurboLinks but with expanded functionality through the addition of turbo frames and turbo...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/11</id>
    <published>2021-08-28T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/the-judo-way-np6Ftd4sUGzK"/>
    <title>The Judo way</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzNj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--e9a1e1c8fcf743a36437b8f1f99f7f1995e28030" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZXc9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9652982111f8b0ec6476e1f5c268dfd8ad57ccc2/lake_walkway.png" filename="lake_walkway.png" filesize="2346232" width="1279" height="751" previewable="true" caption="A walkway at Plitvice Lakes, Croatia"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A walkway at Plitvice Lakes, Croatia" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM2LCJwdXIiOiJibG9iX2lkIn19--4d458290d2064f43b49075cac2651457c9457f4b/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/lake_walkway.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A walkway at Plitvice Lakes, Croatia
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;As a kid I practiced Judo which is a martial art as well as a philosophy. And my Judo teacher gave us a philosophical story which I came to understand only recently and which helped me achieve more than I though was possible. So I wanted to share it.&lt;br&gt;&lt;br&gt;The story was about a small tree in a strong wind. The strong wind could uproot big trees but couldn't do the same to small trees. How so?&lt;br&gt;&lt;br&gt;The teacher explained that the small trees are flexible so they can bend and move with the wind, therefore the wind can never exercise it's full force on them. But the big trees aren't as flexible and their only way to take the force of the wind is to develop deeper roots, stronger trunks and branches. So you don't have to be a big strong tree to take on an opponent such as the strong wind if you can move with the wind.&lt;br&gt;&lt;br&gt;This analogy completely flew over my 11 year old head at the time, but I remembered the story and came to understand it a few years ago.&lt;br&gt;&lt;br&gt;The point of the story, and the philosophy of Judo, is to achieve maximum efficiency with minimum effort (similar to the &lt;a href="https://en.wikipedia.org/wiki/Pareto_principle"&gt;Pareto principle or the 80/20 rule&lt;/a&gt;). The best way to do so is not to fight the incoming force but to use or steerer it in a direction that puts you in an advantageous position. The key to understanding the power of this philosophy is to understand that you can replace the word "force" with anything else, and to accept that an advantageous position might not be exactly what you want but a step in that direction.&lt;br&gt;&lt;br&gt;In the case of the story, if your goal is to stay in the ground then it's much easier to be flexible and move with the wind. You could also develop deeper roots and a stronger trunk but that requires more effort than moving with the wind and eventually a stronger wind could come along and uproot you.&lt;br&gt;&lt;br&gt;I've used this philosophy to attain personal, and professional, goals which range from losing weight to chaining our process at work - things I previously thought were too laborious to be practical.&lt;br&gt;&lt;br&gt;For example, at work we use the Scrum methodology and it's not a good fit for us. The development team is increasingly unhappy about the work we do, and the time we get to work on things that aren't part of a sprint. So we read about other methodologies and composed a list of things we would like to borrow from them to improve our process.&lt;br&gt;&lt;br&gt;We can't just change the process on our own, we have product managers and Scrum masters on which these changes depend on too. So how do we go about implementing those changes?&lt;br&gt;&lt;br&gt;We could form a document and a slideshow, collect all the evidence and studies to support our proposed process changes, and present everything to management. They are rational people, they will understand and accept our proposal, right?&lt;br&gt;&lt;br&gt;While this sounds reasonable I can tell you right away that it will most likely fail. And the worst part is that not only will your process be rejected but all the improvements that could be applied to the current process that are bundled with it will be thrown out as well.&lt;br&gt;&lt;br&gt;The problem is that management often doesn't know, and can't understand, the price of this process - the tech dept piling on, the extra hours, the emergencies on a Friday night which could have been avoided, the endless planning meetings to hack around self imposed system limitations. They either don't see those problems, don't feel them directly, or think they are a badge of honor for you. Your team is shipping value, customers are happy, things get done, so why change something that works and introduce uncertainty the process?&lt;br&gt;&lt;br&gt;The Judo way to do it would be to point out one issue the team felt during the last sprint at each retrospective meeting and propose the process improvement you want to solve it. This is using Scrum against itself - the retrospective meeting is here to solve process problems so the issues your raised have to be discussed and acted upon. If your solution is accepted, or something similar to it, you will be one step closer to your goal.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">As a kid I practiced Judo which is a martial art as well as a philosophy. And my Judo teacher gave us a philosophical story which I came to understand only recently and which helped me achieve more than I though was possible. So I wanted to share it.

The story was about a small tree in a strong...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/10</id>
    <published>2021-08-09T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/new-coat-of-paint-3tgme2PMzsya"/>
    <title>New coat of paint</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyMT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a6da6035ccdb5e438b9a7501ea53f8063cecbe52" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDA9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--ca547c8f90d535fb25f94d5f7b8cb8dae8484ab9/paint.png" filename="paint.png" filesize="769850" width="1203" height="502" previewable="true" caption="Abstract color pour by Pawel Czerwinski"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Abstract color pour by Pawel Czerwinski" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjIxLCJwdXIiOiJibG9iX2lkIn19--bfb9ff970a1962e391b3b74629676e139c444ac6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/paint.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Abstract color pour by Pawel Czerwinski
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Recently I got the urge to write again so I gave this blog a new coat of paint, and decided to give a brief history, the reason I stopped writing and an explanation as to why I moved away from &lt;a href="https://medium.com/"&gt;Medium&lt;/a&gt;.&lt;br&gt;&lt;br&gt;I started this blog back in 2016 as a way to share and discuss ideas and solutions to problem's I've tackled as part of my work, life, education and hobbies.&lt;br&gt;&lt;br&gt;At the time Medium was the hot new, elegant, hosted blog engine with a lot of buzz around it. I jumped on the bandwagon and started my blog there as I didn't have much money at the time, didn't want to build my own blog and didn't want to self-host or pay for WordPress.&lt;br&gt;&lt;br&gt;The first article here &lt;a href="/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5"&gt;is a story about a hackathon I went to in 2016&lt;/a&gt; after which I basically stopped writing. Then, two years later, I switched jobs and technology stacks which lead me to reevaluate most of the things I knew about web development so I started writing again to crystallize my thoughts.&lt;br&gt;&lt;br&gt;Then &lt;a href="do-you-really-need-websockets-343aed40aa9b"&gt;"Do you really need WebSockets"&lt;/a&gt;, an article I wrote because I was tired of explaining to people that SSE and long-polling are not the same thing, became popular. The feedback was overwhelmingly positive and I got hooked to the high of having a popular article.&lt;br&gt;&lt;br&gt;Chasing that high made me imitate my previous success. All articles after "Do you really need WebSockets" were in-depth comparisons. But writing those comparisons wasn't fun, it felt like a chore. Even though I was satisfying my own curiosity I didn't enjoy the process of writing down the conclusion. Why? Because I imposed the structure and tone of "Do you really need WebSockets" on those articles to make them more likely to succeed (at least in my mind).&lt;br&gt;&lt;br&gt;The problem with "Do you really need WebSockets" is that it's written like an academic paper - it's monotone, uses more complicated language just to sound smart and has a predictable structure to every paragraph. When I read those articles now they sound boring and devoid of personality.&lt;br&gt;&lt;br&gt;The first article I ever wrote in this blog represents me better than the last five.&lt;br&gt;&lt;br&gt;Once I realized that I started working on this iteration of the blog.&lt;br&gt;&lt;br&gt;The first step for me was to get away from Medium as the site had many features I didn't want my blog to have.&lt;br&gt;&lt;br&gt;The feature I wanted to get rid off the most was "claps" (Medium's version of likes). Comparing something by how many likes it has produces feedback loops like the one I got into after "Do you really need WebSockets". And the number of likes doesn't tell me anything of value - I don't know if the person giving the like thinks this article is better than the others I wrote, I just know that they liked this one. Even if I did know they liked this article more then the rest I still wouldn't know why.&lt;br&gt;&lt;br&gt;The second feature I didn't want to have were comments. Over the years there were many nice comments, good questions and corrections. But, as with any unmoderated comments section on the Internet, there were ten times more comments from people who clearly didn't read the article and made ungrounded claims calling me out as a liar and dimwit.&lt;br&gt;&lt;br&gt;At first I tried to be kind, explain their claims as false and correct them. But that took more time than I'd like to invest into responding to comments so I tried to ignore those people - this didn't work. Those negative comments influence new readers which then ask me to answer those commenters. In the end I had to answer all comments, which made me bitter, which made my comments sound bitter, which portrayed me as a bitter person.&lt;br&gt;&lt;br&gt;Therefore, no more comments. If someone wants to comment something they should start a thread somewhere on the Internet and link to an article on this blog. All links to any article will show up in the backlinks section and anyone jump into any of those threads.&lt;br&gt;&lt;br&gt;So I developed this web app to re-vitalize the old blog, add new features to it and fix the issues I had with Medium:&lt;br&gt;&lt;br&gt;A custom design that strains the eyes less and that has no accessibility issues.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyMj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c36ba7e3b5aaef23310b7c4526ad6f0b9d1c2cac" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDQ9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--e28c62b2df557cf3e1299d0b6fd7047ac8f9f4ce/new_design.gif" filename="new_design.gif" filesize="3261996" width="1252" height="787" previewable="true" caption="The new design of stanko.io"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="The new design of stanko.io" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjIyLCJwdXIiOiJibG9iX2lkIn19--3e32a96a665ba6162f057ea1a5b77a60ba773c71/new_design.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The new design of stanko.io
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Link previews - because having to open links to see if you want to read what they link to is annoying&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyMz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--96dd9cd9ed03354af6acae3e408ee14f8083831d" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZDg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--2b0458cb976dbafb408f2b0d6b316f996580e2ef/link_previews.gif" filename="link_previews.gif" filesize="960201" width="1252" height="787" previewable="true" caption="Link previews"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Link previews" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjIzLCJwdXIiOiJibG9iX2lkIn19--dfc22c4fbae7b54c7d8716c7c66a8b1ec145f07d/link_previews.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Link previews
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;My own analytics - because that way I can respect my readers' privacy and track backlinks to articles&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--6f70f0a52da377378392b8827763d30c41ea4dd6" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZUE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3346245a03e249db2dbf646a721ebc018e7d9871/backlinks_and_analytics.gif" filename="backlinks_and_analytics.gif" filesize="2843572" width="2442" height="750" previewable="true" caption="Backlinks &amp;amp; analytics on stanko.io"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Backlinks &amp;amp; analytics on stanko.io" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI0LCJwdXIiOiJibG9iX2lkIn19--da15fa5785e1dc81c229d703c2cc9e5ca18ad4c5/backlinks_and_analytics.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Backlinks &amp;amp; analytics on stanko.io
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Visible RSS and ATOM feed for subscriptions&lt;br&gt;&lt;br&gt;It's by no means perfect, but it was fun to do and it shows off who I am, the way I think, my personality and my values. And now that I can write without self-imposed restriction I'm having fun again.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Recently I got the urge to write again so I gave this blog a new coat of paint, and decided to give a brief history, the reason I stopped writing and an explanation as to why I moved away from Medium.

I started this blog back in 2016 as a way to share and discuss ideas and solutions to problem's...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/12</id>
    <published>2019-01-29T12:00:00Z</published>
    <updated>2024-03-22T14:15:00Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/mruby-crash-course-nBLLyxmHhURS"/>
    <title>MRuby crash course</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;The talk introduces MRuby and explains the motivation behind the project by contrasting it with the traditional approach in embedded devices and it's applicability to real-world projects.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">The talk introduces MRuby and explains the motivation behind the project by contrasting it with the traditional approach in embedded devices and it's applicability to real-world projects.</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/7</id>
    <published>2019-01-02T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/graphql-file-upload-with-shrine-45fa26463c68"/>
    <title>GraphQL file upload with Shrine</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyNT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--7f7fa96bda9490c37beda742087490b06bc18834" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZUU9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d18d985c30cfbec03c3af44eabaae3b92d4c1bbd/9719cecb0e0383439f4d62f48bc1b6f8.jpg" filename="9719cecb0e0383439f4d62f48bc1b6f8.jpg" filesize="764670" width="3456" height="1486" previewable="true" presentation="gallery" caption="The GraphQL logo overlayed over a Japanese shrine"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpg"&gt;

      &lt;img alt="The GraphQL logo overlayed over a Japanese shrine" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI1LCJwdXIiOiJibG9iX2lkIn19--322979139475de7248b1e93eb3824faae461539f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--97c88e11642e16f54e1abf60d0fe43b8490e1f40/9719cecb0e0383439f4d62f48bc1b6f8.jpg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The GraphQL logo overlayed over a Japanese shrine
  &lt;/figcaption&gt;
&lt;/figure&gt;At the moment of writing there is no officially supported way to do file upload through &lt;a href="https://en.wikipedia.org/wiki/GraphQL"&gt;GraphQL&lt;/a&gt;. Here is a roundup of all available methods to do file upload through it, their pros and cons.&lt;br&gt;&lt;br&gt;This post grew out of a request on the &lt;a href="https://github.com/shrinerb/shrine"&gt;Shrine&lt;/a&gt; issue tracker — you can find &lt;a href="https://github.com/shrinerb/shrine/issues/244"&gt;the original issue here&lt;/a&gt;. It's still useful for other libraries, but the code examples will only apply to Shrine.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Direct file upload&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Handling uploads is a resource intensive task for the server&lt;/strong&gt;&lt;/b&gt; (using up IO and bandwidth). To resolve this, &lt;b&gt;&lt;strong&gt;we can upload the file directly from the client to our block-storage server&lt;/strong&gt;&lt;/b&gt; (&lt;a href="https://aws.amazon.com/s3/"&gt;AWS S3&lt;/a&gt; or &lt;a href="https://www.digitalocean.com/products/spaces/"&gt;DigitalOcean Spaces&lt;/a&gt;) by using a presigned URL generated on our server.&lt;br&gt;&lt;br&gt;This is in contrast to the classic approach where we would first upload the client's file to our server, and then from our server to the block-storage server.&lt;br&gt;&lt;br&gt;The benefit is that &lt;b&gt;&lt;strong&gt;our server doesn't have to process the file&lt;/strong&gt;&lt;/b&gt; (which uses up IO) and &lt;b&gt;&lt;strong&gt;it doesn't have to upload the file&lt;/strong&gt;&lt;/b&gt; to the block-storage server (more IO usage). It also solves scaling issues related to distributed file caches and distributed resource management.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The downside to this method is apparent when the uploaded file needs to be processed&lt;/strong&gt;&lt;/b&gt;. To process a file our server needs to get the client's form submission containing the URL of the file, download the file, process it and re-upload it to the block-storage server. This uses up bandwidth and IO but it's necessary.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;This method should be satisfactory for most use-cases&lt;/strong&gt;&lt;/b&gt;. If your application doesn't require immediate file processing then the downloading and re-uploading can be done in the background or on a separate machine ( &lt;a href="https://github.com/ysugimoto/aws-lambda-image"&gt;using an AWS lambda&lt;/a&gt;). On AWS you can get the best of both world using the &lt;a href="https://aws.amazon.com/solutions/implementations/serverless-image-handler/"&gt;Serverless image handler&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/shrinerb/shrine/blob/3c14113ce2fb77d1f3e96bd4e4ff10416651fc7c/doc/direct_s3.md"&gt;&lt;b&gt;&lt;strong&gt;Shrine supports this feature out-of-the-box&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; through the &lt;a href="https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/presign_endpoint.rb"&gt;presign plugin&lt;/a&gt;. To get it running you'll need to add the &lt;a href="https://github.com/aws/aws-sdk-ruby"&gt;AWS S3 SDK&lt;/a&gt; to your Gemfile, configure a new storage for Shrine, and expose the file presign endpoint — this is explained in detail in the documentation.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# This code is license under the MIT license&lt;br&gt;# Full text: https://opensource.org/licenses/MIT&lt;br&gt;# Author Stanko K.R. &lt;br&gt;&lt;br&gt;# Usage:&lt;br&gt;# ```ruby&lt;br&gt;# user.avatar = S3UrlToShrineObjectConverter.call(&lt;br&gt;#   arguments[:input][:avatar][:url],&lt;br&gt;#   name: arguments[:input][:avatar][:filename]&lt;br&gt;# )&lt;br&gt;&lt;br&gt;require 'uri'&lt;br&gt;&lt;br&gt;class S3UrlToShrineObjectConverter&lt;br&gt;  def self.call(*args)&lt;br&gt;    new(*args).call&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def initialize(url, name: nil)&lt;br&gt;    @url = url&lt;br&gt;    @name = name&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def call&lt;br&gt;    return unless object&amp;amp;.exists?&lt;br&gt;&lt;br&gt;    {&lt;br&gt;      id: id,&lt;br&gt;      storage: storage,&lt;br&gt;      metadata: {&lt;br&gt;        size: object.content_length,&lt;br&gt;        filename: name || id,&lt;br&gt;        mime_type: object.content_type&lt;br&gt;      }&lt;br&gt;    }&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  protected&lt;br&gt;&lt;br&gt;  attr_reader :url&lt;br&gt;  attr_reader :name&lt;br&gt;&lt;br&gt;  private&lt;br&gt;&lt;br&gt;  def object&lt;br&gt;    @object ||= bucket.object(uri.path)&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def uri&lt;br&gt;    @uri ||= URI(url)&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def bucket&lt;br&gt;    @bucket ||= begin&lt;br&gt;      s3 = AWS::S3.new({}) # TODO: configure this&lt;br&gt;      s3.buckets['your_bucket_name'] # TODO: load this from the app's config&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def id&lt;br&gt;    @id ||= uri.path.split('/', 2).last&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def storage&lt;br&gt;    @storage ||= uri.path.split('/', 2).first&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;Once the client uploads the file it needs to send some kind of reference to the server — the URL of the file, or an ID, or the path of the file (I usually send the original file name as well as the URL).&lt;/p&gt;&lt;pre data-language="plain"&gt;mutation SetAvatar($url: String, $filename: String) {&lt;br&gt;  updateUser(&lt;br&gt;    id: "some_user_uuid"&lt;br&gt;    input: {&lt;br&gt;      avatar: {&lt;br&gt;        filename: $filename&lt;br&gt;        url: $url&lt;br&gt;      }&lt;br&gt;    }&lt;br&gt;  ) {&lt;br&gt;    avatar_url&lt;br&gt;  }&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;The server uses this reference to build a Shrine object and attach it.&lt;br&gt;&lt;br&gt;Note that you can use the &lt;a href="https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/upload_endpoint.rb"&gt;upload_endpoint plugin&lt;/a&gt; instead of the &lt;a href="https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/presign_endpoint.rb"&gt;presign_endpoint plugin&lt;/a&gt;. I advise against this as it enables anyone to upload any content, at any time, to your block-storage. By presiging we solve the "anyone” and "anytime” issues — we have control over who can request presigned URLs to upload or download files.&lt;/p&gt;&lt;h2&gt;Base64 encoding&lt;/h2&gt;&lt;p&gt;This method is &lt;b&gt;&lt;strong&gt;the simplest, yet it's the worst&lt;/strong&gt;&lt;/b&gt; for real-world applications.&lt;br&gt;&lt;br&gt;Your client can encode the file's data as Base64 and set it as the value of a mutation's field.&lt;/p&gt;&lt;pre data-language="plain"&gt;mutation SetAvatar($data: String, $filename: String) {&lt;br&gt;  updateUser(&lt;br&gt;    id: "some_user_uuid"&lt;br&gt;    input: {&lt;br&gt;      avatar: {&lt;br&gt;        filename: $filename&lt;br&gt;        data: $data&lt;br&gt;      }&lt;br&gt;    }&lt;br&gt;  ) {&lt;br&gt;    avatar_url&lt;br&gt;  }&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;While on the surface this seems harmless, since the image has to be uploaded to the server in one way or another, this approach has many unwanted side-effects.&lt;/p&gt;&lt;pre data-language="javascript"&gt;#!/usr/bin/ruby&lt;br&gt;user.avatar_data_uri = arguments[:input][:avatar][:data]&lt;br&gt;user.save&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The biggest issue is memory consumption&lt;/strong&gt;&lt;/b&gt;. Since the image is now part of your request's JSON body it will be parsed as a string, which will drive your memory consumption up. E.g. If you upload ten 5MiB images in one mutation you will have a hash that's at least 50MiB in memory. &lt;b&gt;&lt;strong&gt;Storing whole files in memory can and will crash your application&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;I advise against using this method&lt;/strong&gt;&lt;/b&gt;. If you wish to implement it, you can do so using Shrine's &lt;a href="https://github.com/shrinerb/shrine/blob/a8ba2510bf01981175eb2abe7421c1a86c1bcc9d/lib/shrine/plugins/data_uri.rb"&gt;data_uri plugin&lt;/a&gt;. After you add the plugin to your uploader you will need to set the &lt;b&gt;&lt;strong&gt;&amp;lt;field&amp;gt;_data_uri&lt;/strong&gt;&lt;/b&gt; of your file in the resolver.&lt;br&gt;&lt;br&gt;If you decide to use this method be careful to remove the image upload field from your logs, otherwise your log file will contain a copy of all uploaded images.&lt;/p&gt;&lt;h2&gt;Multipart upload&lt;/h2&gt;&lt;p&gt;Multipart uploads are the standard way of uploading files through HTTP. They are used by most browsers to do file uploads through forms. &lt;b&gt;&lt;strong&gt;Since GraphQL is just a JSON request we can pass files alongside our request&lt;/strong&gt;&lt;/b&gt;, but we need logic to interpret them.&lt;br&gt;&lt;br&gt;I'll be referencing &lt;a href="https://github.com/jaydenseric"&gt;jaydenseric&lt;/a&gt;'s &lt;a href="https://github.com/jaydenseric/graphql-multipart-request-spec"&gt;multipart request specification&lt;/a&gt; as it's supported by several client and server implementations at this moment.&lt;/p&gt;&lt;pre data-language="plain"&gt;mutation UploadGalleryImages {&lt;br&gt;  uploadGalleryImages(&lt;br&gt;    galleryID: "some_gallery_id"&lt;br&gt;    images: [null, null]&lt;br&gt;  ) {&lt;br&gt;    id&lt;br&gt;    filename&lt;br&gt;    url&lt;br&gt;  }&lt;br&gt;}&lt;/pre&gt;&lt;p&gt;A typical GraphQL request consists of three fields &lt;b&gt;&lt;strong&gt;query&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;variables&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;operationName&lt;/strong&gt;&lt;/b&gt;. This spec adds a fourth — &lt;b&gt;&lt;strong&gt;map&lt;/strong&gt;&lt;/b&gt;. The map contains indices and JSON pointers, each index represents an image and each pointer represents the location of the &lt;b&gt;&lt;strong&gt;ActionDispatch::Http::UploadedFile&lt;/strong&gt;&lt;/b&gt; that holds the corresponding image.&lt;br&gt;&lt;br&gt;Say we want to upload two images to a gallery using this method, our GraphQL mutation might look like this:&lt;br&gt;&lt;br&gt;Since the server knows that the &lt;b&gt;&lt;strong&gt;images&lt;/strong&gt;&lt;/b&gt; field is an array of &lt;b&gt;&lt;strong&gt;File&lt;/strong&gt;&lt;/b&gt; types it will match all &lt;b&gt;&lt;strong&gt;null&lt;/strong&gt;&lt;/b&gt; values in the array, in order of appearance, with their index in the &lt;b&gt;&lt;strong&gt;map&lt;/strong&gt;&lt;/b&gt; field and dereference their JSON pointer. If we were to inspect the input params hash we would see the following:&lt;/p&gt;&lt;pre data-language="ruby"&gt;# {&lt;br&gt;#   galleryID: "some_gallery_id",&lt;br&gt;#   images: [&lt;br&gt;#     #&amp;lt;:http::uploadedfile:0x000055fb90c6dbd0&amp;gt;, @original_filename="b.txt", @content_type="text/plain", @headers="Content-Disposition: form-data; name=\"0\"; filename=\"b.txt\"\r\nContent-Type: text/plain\r\n"&amp;gt;,&lt;br&gt;#     #&amp;lt;:http::uploadedfile:0x000055fb90c6da18&amp;gt;, @original_filename="c.txt", @content_type="text/plain", @headers="Content-Disposition: form-data; name=\"1\"; filename=\"c.txt\"\r\nContent-Type: text/plain\r\n"&amp;gt;&lt;br&gt;#   ]&lt;br&gt;# }&lt;br&gt;&lt;br&gt;gallery = Gallery.find(arguemnts[:gallery_id])&lt;br&gt;&lt;br&gt;arguments[:images].map do |file|&lt;br&gt;  # Image has a Shrine uploader attached to `file_data`&lt;br&gt;  Image.create(file: file, gallery: gallery)&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;From here on you can assign those &lt;b&gt;&lt;strong&gt;UploadedFile&lt;/strong&gt;&lt;/b&gt; objects to any of your own objects as usual. No Shrine plugins needed.&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;My suggestion is to use the direct upload method&lt;/strong&gt;&lt;/b&gt; if possible. It's the only method that doesn't require modifications to the GraphQL protocol, it's well understood, and it's used outside of the GraphQL ecosystem (better interoperability with client libraries).&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;If direct uploads won't work for you I would take a gamble on multipart uploads.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;Base64 encoding of upload files is problematic at best and should be avoided in my opinion. Though there are limited use-cases where it's implementation speed out ways the issues it has. Though, those use-cases are rare.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">At the moment of writing there is no officially supported way to do file upload through GraphQL. Here is a roundup of all available methods to do file upload through it, their pros and cons.

This post grew out of a request on the Shrine issue tracker — you can find the original issue here. It's...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/9</id>
    <published>2018-12-25T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/function-composition-ruby-8f91aea21e5f"/>
    <title>Function composition &gt;&gt; Ruby</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--3512fa4be17122014f0acf63d412724d5a2050a5" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZU09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--00f4d71d49f033237a056b8051d3956ddc9f7df7/1den4tFEx_0cCqbh8Iq1mqg.png" filename="1den4tFEx_0cCqbh8Iq1mqg.png" filesize="25448" width="2250" height="750" previewable="true" presentation="gallery" caption="Ruby's new function composition syntax"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Ruby's new function composition syntax" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI3LCJwdXIiOiJibG9iX2lkIn19--fdb6c0facf57a8e2a034a4a6c025fc0d1048e3a6/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/1den4tFEx_0cCqbh8Iq1mqg.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Ruby's new function composition syntax
  &lt;/figcaption&gt;
&lt;/figure&gt;Last week &lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;lt;&amp;lt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; and &lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;gt;&amp;gt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; got merged into Ruby 2.6. This opens the door for function composition. Here’s my opinion as to why this is a great leap forward for Ruby and what needs to improve.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Composition vs. inheritance&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Ruby is an object-oriented language&lt;/strong&gt;&lt;/b&gt; (among others), meaning it has the concept of an object and the class of an objects — which has the concept of inheritance.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;With inheritance we are able to create objects with specialized behavior by inheriting the behavior of a more generalized object (the parent) and specializing it.&lt;/strong&gt;&lt;/b&gt; Let’s build a client for an API that consumes both JSON and XML encoded data. It could look like this.&lt;/p&gt;&lt;pre data-language="ruby"&gt;# Wrapper around a HTTP library&lt;br&gt;class ApiClient; ... end&lt;br&gt;&lt;br&gt;# Knows how to decode JSON responses from the API&lt;br&gt;class JSONApiClient &amp;lt; ApiClient; ... end&lt;br&gt;&lt;br&gt;# Knows how to decode XML responses from the API&lt;br&gt;class XMLApiClient &amp;lt; ApiClient; ... end&lt;br&gt;&lt;br&gt;# Exposes an API's endpoints as methods on an object&lt;br&gt;class MyAppApiClient&lt;br&gt;  attr_reader :json_client&lt;br&gt;  attr_reader :xml_client&lt;br&gt;&lt;br&gt;  def initialize(api_token)&lt;br&gt;    @json_client = JSONApiClient.new(api_token)&lt;br&gt;    @xml_client = XMLApiClient.new(api_token)&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def current_account&lt;br&gt;    json_client.get('/api/current_account')&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def balance&lt;br&gt;    xml_client.get('/api/current_account/balance')&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;class MyAppApiClient&lt;br&gt;  attr_reader :json_client&lt;br&gt;  attr_reader :xml_client&lt;br&gt;&lt;br&gt;  def initialize(api_token)&lt;br&gt;    @json_client = ApiClient.new(API_KEY, JSON.method(:parse).to_proc)&lt;br&gt;    @xml_client = ApiClient.new(API_KEY, XML.method(:parse).to_proc)&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def current_account&lt;br&gt;    json_client.get('/api/current_account')&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  def balance&lt;br&gt;    xml_client.get('/api/current_account/balance')&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;array = [1,2,3,4,5]&lt;br&gt;&lt;br&gt;array.map # =&amp;gt; #&lt;br&gt;&lt;br&gt;array.map { |i| i * 2 } # =&amp;gt; [2, 4, 6, 8, 10]&lt;/pre&gt;&lt;p&gt;This certainly would get the job done. But &lt;b&gt;&lt;strong&gt;this code has two issues&lt;/strong&gt;&lt;/b&gt; that we need to address.&lt;br&gt;&lt;br&gt;First, both &lt;b&gt;&lt;strong&gt;JSONApiClient&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;XMLApiClient&lt;/strong&gt;&lt;/b&gt; are &lt;b&gt;&lt;strong&gt;tightly coupled to their parent and provide little functionality besides the functionality they inherited&lt;/strong&gt;&lt;/b&gt; — meaning that any change to the parent class would probably break the classes inheriting from it.&lt;br&gt;&lt;br&gt;Second, &lt;b&gt;&lt;strong&gt;JSONApiClient&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;XMLApiClient&lt;/strong&gt;&lt;/b&gt; are &lt;b&gt;&lt;strong&gt;too specialized&lt;/strong&gt;&lt;/b&gt;. Inheritance is all about specialization, but having too specialized classes can cause headaches — they are hard to extend (when requirements change) and introduce unnecessary complexities (for a human, it’s hard to remember many different kinds of objects and what they do).&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;We can address both issues by passing the desired parser as an argument to ApiClient&lt;/strong&gt;&lt;/b&gt;. This is in-line with the &lt;a href="https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle"&gt;open-close&lt;/a&gt; principle, as now we can create an &lt;b&gt;&lt;strong&gt;ApiClient&lt;/strong&gt;&lt;/b&gt; that can parse any kind of data without needing to subclass it.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;We took an existing class/service/object/function and combined it with another to get more specialized behavior.&lt;/strong&gt;&lt;/b&gt; This is the basic idea behind &lt;a href="https://en.wikipedia.org/wiki/Function_composition_(computer_science)"&gt;function composition&lt;/a&gt;.&lt;br&gt;&lt;br&gt;While the above example is actually an example of &lt;a href="https://en.wikipedia.org/wiki/Dependency_injection"&gt;dependency injection&lt;/a&gt;, it is a great outline for the things that are possible with function composition in Ruby. Also, note that the above approach is limited to the configurable dependencies we accept through the initializer.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;We use composition every day without even noticing it.&lt;/strong&gt;&lt;/b&gt; &lt;b&gt;&lt;strong&gt;Enumerable#map&lt;/strong&gt;&lt;/b&gt; uses composition to enable us to transform arrays.&lt;br&gt;&lt;br&gt;The &lt;b&gt;&lt;strong&gt;map&lt;/strong&gt;&lt;/b&gt; method is of limited usefulness on it’s own. But when we combine it with a block (another function) it can transform whole datasets.&lt;/p&gt;&lt;h2&gt;&lt;code&gt;Proc#&amp;gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;Proc#&amp;lt;&amp;lt;&lt;/code&gt;&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;gt;&amp;gt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; and &lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;lt;&amp;lt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; were introduced in Ruby 2.6. &lt;b&gt;&lt;strong&gt;They provide a substantial quality-of-life improvement when it comes to composing functions.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;gt;&amp;gt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; is similar to Elixir’s pipeline, but instead of returning a result it returns a proc — a callable object.&lt;br&gt;&lt;br&gt;In mathematical terms, &lt;b&gt;&lt;code&gt;&lt;strong&gt;f(x) &amp;gt;&amp;gt; g(x)&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; is the same as &lt;b&gt;&lt;code&gt;&lt;strong&gt;g(f(x))&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Let’s go back to our &lt;b&gt;&lt;strong&gt;ApiClient&lt;/strong&gt;&lt;/b&gt; example:&lt;/p&gt;&lt;pre data-language="ruby"&gt;f = -&amp;gt; x { x + 2 }&lt;br&gt;g = -&amp;gt; x { x * 2 }&lt;br&gt;&lt;br&gt;# h is the composition of f and g&lt;br&gt;h = f &amp;gt;&amp;gt; g&lt;br&gt;# h is the same as -&amp;gt; x { (x + 2) * 2 }&lt;br&gt;&lt;br&gt;[1, 2, 3].map(&amp;amp;h) # =&amp;gt; [6, 8, 10]&lt;br&gt;# This is exactly the same as&lt;br&gt;[1, 2, 3].map(&amp;amp;f).map(&amp;amp;g) # =&amp;gt; [6, 8, 10]&lt;br&gt;&lt;br&gt;fetch_transactions =&lt;br&gt;  ApiClient.new(api_token).method(:get)&lt;br&gt;  &amp;gt;&amp;gt; JSON.method(:parse)&lt;br&gt;  &amp;gt;&amp;gt; (-&amp;gt; response { response['data']['transactions'] })&lt;br&gt;&lt;br&gt;fetch_transaction.call('/api/current_user/transactions')&lt;br&gt;&lt;br&gt;f = -&amp;gt; x { x + 2 }&lt;br&gt;g = -&amp;gt; x { x * 2 }&lt;br&gt;&lt;br&gt;# h is the composition of g and f&lt;br&gt;h = f &amp;lt;&amp;lt; g&lt;br&gt;# h is the same as -&amp;gt; x { (x * 2) + 2 }&lt;br&gt;&lt;br&gt;[1, 2, 3].map(&amp;amp;h) # =&amp;gt; [4, 6, 8]&lt;br&gt;# This is exactly the same as&lt;br&gt;[1, 2, 3].map(&amp;amp;g).map(&amp;amp;f) # =&amp;gt; [4, 6, 8]&lt;/pre&gt;&lt;p&gt;Notice that we didn’t pass anything to &lt;b&gt;&lt;strong&gt;ApiClient&lt;/strong&gt;&lt;/b&gt;, we didn’t need to do dependency injection. &lt;b&gt;&lt;strong&gt;This solves the configuration problem we had before. And we are able to create highly specialized functions on-the-fly.&lt;/strong&gt;&lt;/b&gt; The above example creates a function that returns all transaction from an API endpoint.&lt;br&gt;&lt;br&gt;Note, if you want Elixir style pipelines that return a result check out &lt;b&gt;&lt;strong&gt;yield_self&lt;/strong&gt;&lt;/b&gt; or the new alias for it &lt;b&gt;&lt;strong&gt;then&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;On the other hand, &lt;b&gt;&lt;code&gt;&lt;strong&gt;Proc#&amp;lt;&amp;lt;&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt;&lt;b&gt;&lt;strong&gt; is more in-line with Haskell style composition:&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;Or, in mathematical terms, &lt;b&gt;&lt;code&gt;&lt;strong&gt;f(x) &amp;lt;&amp;lt; g(x)&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt; is the same as &lt;b&gt;&lt;code&gt;&lt;strong&gt;f(g(x))&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;Both &lt;b&gt;&lt;strong&gt;&amp;lt;&amp;lt;&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;&amp;gt;&amp;gt;&lt;/strong&gt;&lt;/b&gt; are basically the same, &lt;b&gt;&lt;strong&gt;which one to use is only subject to your preference.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Function composition is very useful in languages that don’t have the concept of classes and inheritance&lt;/strong&gt;&lt;/b&gt; as it enables you to “inherit” the behavior of a function and extend/specialize its behavior. Like in the following example.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Since we do have inheritance in Ruby this kind of composition is less useful.&lt;/strong&gt;&lt;/b&gt; Yet it gives us the ability to create utility functions on-the-fly thus &lt;b&gt;&lt;strong&gt;it encouraging us to create methods/classes that can be extended through the open-close principle / dependency injection, and &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://en.wikipedia.org/wiki/Composition_over_inheritance"&gt;&lt;b&gt;&lt;strong&gt;composition-over-inheritance&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;.&lt;/strong&gt;&lt;/b&gt; In my opinion, this is a big step forward for the language.&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;As its implemented now, composition sticks out like a sore thumb. &lt;b&gt;&lt;strong&gt;The ecosystem needs to grow to accommodate for this feature.&lt;/strong&gt;&lt;/b&gt; Constantly calling &lt;b&gt;&lt;strong&gt;#method&lt;/strong&gt;&lt;/b&gt; on a class/module is confusing and distracting — a short hand operator for this purpose would be welcome (I would propose &lt;b&gt;&lt;code&gt;&lt;strong&gt;&amp;amp;[Json].parse&lt;/strong&gt;&lt;/code&gt;&lt;/b&gt;). The &lt;b&gt;&lt;strong&gt;map&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;reduce&lt;/strong&gt;&lt;/b&gt; and other enumerator methods are hard to compose since they are implemented on an instance of an object — an &lt;b&gt;&lt;strong&gt;Enum&lt;/strong&gt;&lt;/b&gt; module exposing them would be nice.&lt;/p&gt;&lt;pre data-language="ruby"&gt;require 'net/http'&lt;br&gt;require 'uri'&lt;br&gt;require 'json'&lt;br&gt;&lt;br&gt;fetch =&lt;br&gt;  self.method(:URI) \&lt;br&gt;  &amp;gt;&amp;gt; Net::HTTP.method(:get) \&lt;br&gt;  &amp;gt;&amp;gt; JSON.method(:parse) \&lt;br&gt;  &amp;gt;&amp;gt; -&amp;gt; response { response.dig('bpi', 'EUR', 'rate') || '0' } \&lt;br&gt;  &amp;gt;&amp;gt; -&amp;gt; value { value.gsub(',', '') } \&lt;br&gt;  &amp;gt;&amp;gt; self.method(:Float)&lt;br&gt;&lt;br&gt;fetch.call('https://api.coindesk.com/v1/bpi/currentprice.json') # =&amp;gt; 3530.6782&lt;/pre&gt;&lt;p&gt;If somebody shares these pain points, and agrees with me. &lt;a href="https://gist.github.com/monorkin/7e1a597afb0ad295d5b8ecfb951d60af"&gt;I’ve created a draft implementation of those features&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Last week Proc#&lt;&lt; and Proc#&gt;&gt; got merged into Ruby 2.6. This opens the door for function composition. Here’s my opinion as to why this is a great leap forward for Ruby and what needs to improve.

Composition vs. inheritanceRuby is an object-oriented language (among others), meaning it has the...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/8</id>
    <published>2018-12-25T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/best-image-uploader-for-rails-revisited-3b3b7618cc4c"/>
    <title>Best image uploader for Rails — Revisited</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIyNj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--8d08066a37d06fbdeda33d124e05982781c2af7b" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZUk9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--925ecdedabf202197b56ff11fcfbd9d569d25c43/ruby.jpeg" filename="ruby.jpeg" filesize="90694" width="1080" height="517" previewable="true" caption="Hand holding a plastic Ruby gemstone"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Hand holding a plastic Ruby gemstone" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjI2LCJwdXIiOiJibG9iX2lkIn19--5737419acd136d37ff0ad189425e52be7db53733/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/ruby.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Hand holding a plastic Ruby gemstone
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Three years ago I wrote about &lt;a href="https://infinum.co/the-capsized-eight/best-rails-image-uploader-paperclip-carrierwave-refile"&gt;how to choose the right uploader gem for your project&lt;/a&gt;. Since the time the original article has been published, all mentioned libraries got updated, one got deprecated, and two new libraries have appeared. I feel it's time to revisit this topic.&lt;/div&gt;&lt;h2&gt;Why do we use uploader gems?&lt;/h2&gt;&lt;div&gt;&lt;a href="http://rubyonrails.org/"&gt;Rails&lt;/a&gt; handles file-upload natively. When a file gets uploaded to your app it's represented by an &lt;a href="https://web.archive.org/web/20171013094615/http://api.rubyonrails.org/classes/ActionDispatch/Http/UploadedFile.html"&gt;UploadedFile&lt;/a&gt; object, which is a wrapper around the underlying &lt;a href="https://ruby-doc.org/stdlib-2.5.0/libdoc/tempfile/rdoc/Tempfile.html"&gt;Tempfile&lt;/a&gt; object that contains the data sent to the server.&lt;br&gt;&lt;br&gt;After we get the file we have multiple options what to do with it:&lt;/div&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Cache it&lt;/strong&gt; — if the file is uploaded through a form we want to temporarily store the file in case the form fails to validate, so that the user doesn't have to re-upload it. This lowers bandwidth consumption and improves UX.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Process it&lt;/strong&gt; — more often than not people want to create smaller or cropped versions of images that users upload. In the case of PDFs / DOCs we might want to create thumbnails. In general we want to process the data somehow.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Store it&lt;/strong&gt; — we want to store the uploaded file without name clashes and corruption.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Forward the data to a file storage server&lt;/strong&gt; — to lower bandwidth to our own servers we usually want to store the files on services like &lt;a href="https://aws.amazon.com/s3/"&gt;Amazon's S3&lt;/a&gt; or &lt;a href="https://www.digitalocean.com/products/spaces/"&gt;DigitalOcean's Spaces&lt;/a&gt;. Perhaps we also want to send the data to a CDN (like &lt;a href="https://www.cloudflare.com/"&gt;CloudFlare&lt;/a&gt;) so that the file gets served faster to users all over the world.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Stream the file back to users&lt;/strong&gt; — if our app also handles serving of the file it would be best if we could stream its contents instead of loading it into memory.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Process it on-the-fly&lt;/strong&gt; — if we don't know what kind of processing our client's require from the data, we can do it on-the-fly. E.g. different phones can have different resolutions, instead of serving all devices the same sized image a device can request the image best suited for it's display. The image will then be processed to the desired dimensions and passed to the device.&lt;/li&gt;&lt;/ul&gt;&lt;div&gt;&lt;strong&gt;Not to re-invent the wheel, and not to introduce bugs into our codebase, we reach for a gem that will do all or most of those things for us.&lt;/strong&gt;&lt;/div&gt;&lt;h2&gt;ActiveStorage &amp;amp; Shrine&lt;/h2&gt;&lt;div&gt;Since I published the previous article two new “mainstream” libraries have appeared. &lt;a href="http://rubyonrails.org/"&gt;Rails'&lt;/a&gt; own &lt;a href="https://github.com/rails/rails/tree/master/activestorage"&gt;ActiveStorage&lt;/a&gt; and &lt;a href="https://github.com/shrinerb/shrine"&gt;janko-m's Shrine&lt;/a&gt;.&lt;/div&gt;&lt;h2&gt;ActiveStorage&lt;/h2&gt;&lt;div&gt;ActiveStorage now represents the standard (or at least the Rails way) for handling file-upload and storage on file servers. It integrates seamlessly with &lt;a href="https://github.com/rails/rails/tree/master/activerecord"&gt;ActiveRecord&lt;/a&gt;, providing an elegant API to upload, download, delete, and process files.&lt;br&gt;&lt;br&gt;&lt;strong&gt;All file processing is done on the fly.&lt;/strong&gt; This was a gripe I had with Refile in the original article as it made it unusable without a CDN — to cache processed images. &lt;strong&gt;ActiveStorage fixed that by storing all created versions after initial processing.&lt;/strong&gt; Note that &lt;strong&gt;only the web app can create a version of the file as it has to sign the file's URL&lt;/strong&gt; — which enables it to sanitize and rate-limit clients' requests. And at the time of writing &lt;a href="https://github.com/rails/rails/issues/32236"&gt;there are some difficulties with connecting ActiveStorage to a CDN&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;strong&gt;One feature that is lacking is the ability to create custom file processors.&lt;/strong&gt; In Rails 5.2 ActiveStorage uses the &lt;a href="https://github.com/minimagick/minimagick"&gt;minimagick gem&lt;/a&gt; directly to process images, but in the future it will use the &lt;a href="https://github.com/janko-m/image_processing"&gt;image_processing gem&lt;/a&gt; which can potentially expand the list of available options. But, it's currently only able to process files to images (it can create PDF and video previews). There is no way to re-encode a video file, or to e.g. count the number of words in a document. But it offers many macros for working with images and &lt;a href="https://github.com/jcupitt/ruby-vips"&gt;vips&lt;/a&gt; support is on it's way.&lt;br&gt;&lt;br&gt;&lt;strong&gt;ActiveStorage advocates for direct uploads&lt;/strong&gt; (uploading directly to the file server), this abolishes the need for caching.&lt;br&gt;&lt;br&gt;The biggest issue I've experienced is that &lt;strong&gt;it's coupled to ActiveRecord.&lt;/strong&gt; This forces developers' choice of ORM, and makes potential library migrations more difficult. Today it's not uncommon to see &lt;a href="https://github.com/rom-rb/rom"&gt;ROM&lt;/a&gt; or &lt;a href="https://github.com/jeremyevans/sequel"&gt;Sequel&lt;/a&gt; in an Rails app.&lt;br&gt;&lt;br&gt;&lt;strong&gt;I'm glad that Rails introduced a standard solution.&lt;/strong&gt; Out-of-the-box file upload was something that was missing in the framework to make it the perfect one-stop-shop. And, in my opinion, ActiveStorage covers 90% of peoples' file uploading and processing needs. With it, you can add file upload to your Rails app in minutes.&lt;/div&gt;&lt;h2&gt;Shrine&lt;/h2&gt;&lt;div&gt;&lt;strong&gt;DISCLAMER:&lt;/strong&gt; I know the author of the gem and consider him a friend. Therefore my opinion can be interpreted as biased. I'll try to be as objective as possible.&lt;br&gt;&lt;br&gt;In the previous article I named Carrierwave the Swiss army knife of Rails uploaders. This time around that honor goes to &lt;a href="http://github.com/janko-m/shrine"&gt;Shrine&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;strong&gt;Shrine takes CarrierWave's idea of uploader classes and refines it further&lt;/strong&gt; by introducing a &lt;a href="https://janko.io/the-plugin-system-of-sequel-and-roda/"&gt;Roda's / Sequel's plugin system&lt;/a&gt; and cleaning up the API.&lt;br&gt;&lt;br&gt;The plugin system makes it agnostic to many things like ORMs, frameworks, and other. So-much-so that &lt;strong&gt;it can be used with any Rack app.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;&lt;a href="http://shrinerb.com/"&gt;&lt;strong&gt;It comes bundled with many plugins&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; which provide a lot of configuration options and compatibility with many libraries.&lt;/strong&gt; E.g. both ActiveRecord and Sequel are supported out-of-the-box.&lt;br&gt;&lt;br&gt;There exists the ability for &lt;strong&gt;parallel and background file processing.&lt;/strong&gt; This not only speeds things up, but makes large file processing (like video encoding) easier to handle.&lt;br&gt;&lt;br&gt;Of course, &lt;strong&gt;there's the ability to do direct uploads&lt;/strong&gt;, same as ActiveStorage. The difference between the two implementation being the underlying JS library. ActiveStorage rolled their own JS library, while Shrine decided to use Uppy (the most-popular direct-upload library at the time).&lt;br&gt;&lt;br&gt;&lt;strong&gt;Different file storage servers, and caching is supported out-of-the-box.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Metadata, such as the original name, extension, file size, checksum and other arbitrary data can be stored alongside the file. This metadata feature also enables the storing of versions (e.g. different sizes/crops of an image or different encodings of a video).&lt;br&gt;&lt;br&gt;&lt;strong&gt;Something that I've only seen is Shrine is the ability to migrate data around.&lt;/strong&gt; E.g. there is a plugin that enables you to move your files from one file-store to another. Or to copy data over from one model to another.&lt;br&gt;&lt;br&gt;&lt;strong&gt;The only lacking feature is on-the-fly processing.&lt;/strong&gt; Though, personally, I have never found that to be the issue. But after ActiveStorage did it I'd like to see the same functionality in Shrine. &lt;a href="https://shrinerb.com/rdoc/files/doc/processing_md.html#label-On-the-fly+processing"&gt;Note that it's possible to achieve on-the-fly processing if you integrate with Dragonfly or Cloudinary&lt;/a&gt;, but there is no out-of-the-box solution.&lt;br&gt;&lt;br&gt;From my experience, people complain that they have to configure Shrine for Rails. I've never found that to be an issue, but to address those people I'd hope the author creates a wrapper gem to ease the integration with sane defaults for most configuration options.&lt;/div&gt;&lt;h2&gt;Paperclip&lt;/h2&gt;&lt;div&gt;Sadly, with the advent of ActiveStorage we also saw the deprecation of &lt;a href="https://github.com/thoughtbot/paperclip"&gt;Paperclip&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Paperclip didn't improve much since the last round-up, but it didn't need to. It always was the simplest and quickest solution, and intended only to attach files and light processing.&lt;br&gt;&lt;br&gt;Much of what it does, ActiveStorage does better and it's a bundled-in part of Rails. Though, note that &lt;a href="https://github.com/rails/rails/issues/31656"&gt;some features like validation are still missing in ActiveStorage&lt;/a&gt;. It was a good decision to deprecate the project as it couldn't compete with Rail's own solution.&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;CarrierWave, Refile &amp;amp; Dragonfly&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;When it comes to &lt;a href="https://github.com/carrierwaveuploader/carrierwave"&gt;CarrierWave&lt;/a&gt;, &lt;a href="https://github.com/refile/refile"&gt;Refile&lt;/a&gt; and &lt;a href="https://github.com/markevans/dragonfly"&gt;Dragonfly&lt;/a&gt; I have to admit &lt;strong&gt;I haven't used either of them in quite the while as ActiveStorage and Shrine completely substituted them in my tool belt.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Those libraries haven't changed much in the last two years.&lt;/div&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;div&gt;In my opinion, today &lt;strong&gt;you have two choices when it comes to file uploaders in Rails&lt;/strong&gt; — either ActiveStorage or Shrine.&lt;br&gt;&lt;br&gt;&lt;strong&gt;ActiveStorage is the perfect solution if you just need to store a file, do some light processing and forget about it.&lt;/strong&gt; I'd guess that this is enough for 90% of the projects that exist.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If your applications requires more advanced processing or storage options, or any kind of custom file processing go with Shrine.&lt;/strong&gt; Its plugins system makes it easily extendable, and there already exist quite a few plugins out there.&lt;br&gt;&lt;br&gt;&lt;strong&gt;CarrierWave, Refile and Dragonfly are by no means dead.&lt;/strong&gt; They do have their audiances, but I've found that ActiveStorage or Shrine can do most if not more than those libraries can, so I'd recommend those two over the others for new projects.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If you aren't using Rails&lt;/strong&gt;, then there is only one real option for you — Shrine.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Three years ago I wrote about how to choose the right uploader gem for your project. Since the time the original article has been published, all mentioned libraries got updated, one got deprecated, and two new libraries have appeared. I feel it's time to revisit this topic.
Why do we use uploader...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/6</id>
    <published>2018-10-20T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/licensing-software-b13d043e2a93"/>
    <title>Licensing software</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--cf4639cc101b586a108238dae9418494d8e4a64f" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZkE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--40044bef25ca697b2a881979f5e21cd7d17721f9/OQBL7rd0Y9yFN92dSkV1Uw.jpeg" filename="OQBL7rd0Y9yFN92dSkV1Uw.jpeg" filesize="1055264" width="4000" height="1734" previewable="true" caption="My messy desk"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="My messy desk" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQwLCJwdXIiOiJibG9iX2lkIn19--86bc3ab02581a75cc78ff353e4056d319b7940b0/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/OQBL7rd0Y9yFN92dSkV1Uw.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      My messy desk
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Recently I've started working on a small Ruby library. While I was sketching the architecture of it to I was listening to some lectures from Richard M. Stallman which got me thinking about how I should license my library.&lt;br&gt;&lt;br&gt;Note: I'm by no means a legal expert. Everything written here is what I've found while researching the issue. Some things may not apply to the region you live in.&lt;/div&gt;&lt;h2&gt;Why is licensing important?&lt;/h2&gt;&lt;div&gt;&lt;strong&gt;Unless you provide a license with your code, the code (legally) can't be redistributed by others.&lt;/strong&gt; It falls under your personal copyright, and only you are allowed to redistribute it, authorize copies, modify it, etc.&lt;br&gt;&lt;br&gt;I wasn't aware of this until, one day, I got an email from a complete stranger asking if I could add a license to an old project of mine so that his company could fork and modify it to their liking.&lt;br&gt;&lt;br&gt;This caught me off-guard, I always thought that public, unlicensed, code is free for the taking. Much like things in the public domain. But, it turns out that code (just like essays, books, poems, songs, …) belong to the person who wrote them unless there is a contract specifying otherwise (e.g. a license or a contract with the company you work for). This same issue was covered by Jeff Antwood back in 2007 with the post "&lt;a href="https://blog.codinghorror.com/pick-a-license-any-license/"&gt;Pick a License, Any License&lt;/a&gt;".&lt;br&gt;&lt;br&gt;For that reason you'll often hear something along the lines of &lt;em&gt;"Experienced developers won't touch unlicensed code because they have no legal right to use it."&lt;/em&gt;&lt;/div&gt;&lt;h2&gt;How to choose a license for your project?&lt;/h2&gt;&lt;div&gt;Choosing the right license four your project can be a daunting task. &lt;strong&gt;A license determines which rights you hold/give up as the author and under what conditions others can use your project&lt;/strong&gt; — you can think of it as your business model, of sorts.&lt;br&gt;&lt;br&gt;Licenses are often written in a confusing to read, but legally correct, manner which means it takes a couple of readings before you understand the full legal implications of the text. Since I've seen at least 20 different software licenses over the years, and I didn't want to spend the time to read all of them through &lt;strong&gt;I've decided to narrow my efforts to the most popular ones and research those.&lt;/strong&gt;&lt;/div&gt;&lt;h2&gt;What to choose?&lt;/h2&gt;&lt;div&gt;To find out which licenses are most popular in the community I decided to look at GitHub's data on Google's BigQuery platform. GitHub catalogs all repositories by the license they use. It's far from perfect as some projects have multiple-licenses (e.g. &lt;a href="https://github.com/rust-lang/rust/blob/c57deb923e56e04634b7e4b3fdc0f84ea0d41ed4/COPYRIGHT"&gt;Rust&lt;/a&gt;) and some have custom licenses (e.g. &lt;a href="https://github.com/ruby/ruby/blob/9cbe4553f4e25d0a9beb4b6393b601c1d0ab3a17/COPYING"&gt;Ruby&lt;/a&gt;) which will be catalogued as unlicensed, but the data should be indicative of a trend.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--182061a85e25744a55286507028be2f44820df2d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZTA9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--2e75bacdf86298f1f7b5f2ada2cd085babdb25cb/D03sRAEqi3hZGi0v_18dIg.png" filename="D03sRAEqi3hZGi0v_18dIg.png" filesize="24960" width="2664" height="736" previewable="true" caption="Representation of licenses in all GitHub projects as a percentage"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Representation of licenses in all GitHub projects as a percentage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM3LCJwdXIiOiJibG9iX2lkIn19--abbe6112eaaf076d71063b74f291dc84b5244696/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/D03sRAEqi3hZGi0v_18dIg.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Representation of licenses in all GitHub projects as a percentage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;strong&gt;The above graph represents the percentage of repositories using a particular license.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;To get that data from BigQuery I've used the following query to extract the count of projects using a particular license for C, C++, Elixir, Haskell, Java, JavaScript, Kotlin, PHP, Python, Ruby &amp;amp; Rust to construct the data-set.&lt;/div&gt;&lt;pre&gt;SELECT licenses.license AS license, COUNT(licenses.repo_name) AS count
FROM [bigquery-public-data:github_repos.licenses] AS licenses
JOIN [bigquery-public-data:github_repos.languages] AS languages ON licenses.repo_name = languages.repo_name
WHERE languages.language.name = ''
GROUP BY license&lt;/pre&gt;&lt;div&gt;It's visible form the graph that &lt;strong&gt;the five most popular licenses are (in order) MIT, Apache-2.0, GPLv2, GPLv3 and BSD-3-clauses.&lt;/strong&gt; It's also visible that there is a relatively small amount of unlicensed, custom-licensed and multi-licensed repositories.&lt;br&gt;&lt;br&gt;Now that our search has been narrowed down to only five licenses I can read and research each one of them and specify a use-case for each.&lt;/div&gt;&lt;h2&gt;MIT&lt;/h2&gt;&lt;div&gt;&lt;a href="https://opensource.org/licenses/MIT"&gt;The MIT license&lt;/a&gt; is by far &lt;strong&gt;the most popular license with about 48% of all repositories on GitHub adopting it&lt;/strong&gt;. It's also the simplest of the five.&lt;br&gt;&lt;br&gt;&lt;a href="https://twitter.com/dhh"&gt;DHH&lt;/a&gt; (David Heinemeier Hansson) once translated it nicely as "&lt;a href="https://www.flickr.com/photos/rooreynolds/243810133/"&gt;&lt;strong&gt;I don't owe you shit&lt;/strong&gt;&lt;/a&gt;".&lt;br&gt;&lt;br&gt;The license states that the person using the project has to include the license in any derivative work (e.g. forks — like in the case of &lt;a href="https://github.com/jeremyevans/roda/blob/d36844117a6dc33794826b92c6e24f4151bc34cb/MIT-LICENSE"&gt;Roda&lt;/a&gt; which is a fork of &lt;a href="https://github.com/soveran/cuba"&gt;Cuba&lt;/a&gt;), that they are free to do what ever they like with your code and that you as the author can't be held liable for any bugs or unintended side-effects.&lt;br&gt;&lt;br&gt;That means that people are free to include your code in their projects (provided the copyright/license notice), you can't be held liable for any kind of unwanted behavior, and that nobody is required to upstream or open-source any bug fixes or changes they make.&lt;br&gt;&lt;br&gt;&lt;strong&gt;This license is extremely permissive making it an excellent choice for libraries, as well as applications.&lt;/strong&gt; It's mostly catered to open-source projects. Though, in my opinion and depending on your desires, GPL may be better for applications. Note that with MIT any one can make a closed-source derivative work, this makes MIT really popular as it doesn't force the end-user's choice.&lt;br&gt;&lt;br&gt;&lt;strong&gt;There is a gotcha' when working with the MIT license&lt;/strong&gt;, &lt;a href="https://github.com/VSCodium/vscodium#why-does-this-exist"&gt;one exploited by Microsoft's Visual Studio Code&lt;/a&gt;. &lt;strong&gt;MIT only covers the source code, not the compiled or binary version&lt;/strong&gt; which enabled developers to add additional clauses to the release version of the app/library.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzOD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--5b545083b93416896d4358b0a3f4ed60a4d35f59" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZTQ9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--26993dd3184a9ffddb44875ddb4bbe7ebe66d644/y6vD5b6zkq9Da6JXq6tbbw.png" filename="y6vD5b6zkq9Da6JXq6tbbw.png" filesize="279835" width="2000" height="1696" previewable="true" caption="License popularity by language, expressed as a percentage; EDIT: &amp;quot;Unlicensed&amp;quot; is actually the Unlicense license"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="License popularity by language, expressed as a percentage; EDIT: &amp;quot;Unlicensed&amp;quot; is actually the Unlicense license" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM4LCJwdXIiOiJibG9iX2lkIn19--94d316a9ee177ba2a8f7014ea31c71b193048329/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/y6vD5b6zkq9Da6JXq6tbbw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      License popularity by language, expressed as a percentage; EDIT: "Unlicensed" is actually the Unlicense license
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The MIT license is extremely popular in all of the languages I sampled but Java, Kotlin &amp;amp; Haskell where Apache-2.0 and BSD-3-clauses were more popular. And even in those languages, MIT was on second place. &lt;strong&gt;I'd attribute it's popularity to it's do-what-you-want approach to licensing.&lt;/strong&gt;&lt;/div&gt;&lt;h2&gt;Apache-2.0&lt;/h2&gt;&lt;div&gt;&lt;a href="https://opensource.org/licenses/Apache-2.0"&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; is more complicated in legal terms than MIT, but basically sets the same expectations.&lt;/strong&gt; The license must be provided with each copy of the source. The author(s) can't be held liable in any way, shape or form. It grants the end-user full rights regarding patenting on any contributed code given it doesn't infringe on any patents.&lt;br&gt;&lt;br&gt;The biggest difference from MIT is that &lt;strong&gt;any modifications done to the original project have to be redistributed with a notice that the files have been modified.&lt;/strong&gt; And &lt;strong&gt;a warranty can be applied&lt;/strong&gt; to the project (e.g. for a fee).&lt;br&gt;&lt;br&gt;&lt;strong&gt;It's quite permissive making it an excellent choice for both libraries and applications.&lt;/strong&gt; Especially given the patents and warrant clause, which makes it more suitable for commercial use.&lt;br&gt;&lt;br&gt;Same as with MIT, Apache-2.0 allows end-users to modify your project and redistribute it under minimal terms, without the need to open-source or upstream modification.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzIzOT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--1b57081a0863a82edd7a8d97d7b5681ea025123c" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZTg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--239ac3770fc6db977da47016a6e8ee25e8ef8332/pE72mKU20oWLCGSFTVfXVw.png" filename="pE72mKU20oWLCGSFTVfXVw.png" filesize="184730" width="2000" height="1133" previewable="true" caption="License popularity in Java &amp;amp; Kotlin projects, expressed as a percentage"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="License popularity in Java &amp;amp; Kotlin projects, expressed as a percentage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjM5LCJwdXIiOiJibG9iX2lkIn19--f6b4c5580e771fd67a18223e019582e76ac18b4c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/pE72mKU20oWLCGSFTVfXVw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      License popularity in Java &amp;amp; Kotlin projects, expressed as a percentage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Interestingly enough, Apache-2.0 is the most popular license for Java and Kotlin projects. I'd guess that because &lt;a href="https://github.com/apache"&gt;the Apache Foundation predominately uses Java for their projects&lt;/a&gt; this drives up the number of Java projects with that license. But' I can't explain exactly why these two languages are the outliers.&lt;/div&gt;&lt;h2&gt;GPLv2 &amp;amp; GPLv3&lt;/h2&gt;&lt;div&gt;Here &lt;strong&gt;I'm going to cover only &lt;/strong&gt;&lt;a href="https://www.gnu.org/licenses/rms-why-gplv3.html"&gt;&lt;strong&gt;GPLv3&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://www.gnu.org/licenses/rms-why-gplv3.html"&gt;as v2 and v3 are much the same (yet incompatible) expect for patents and a loop hole&lt;/a&gt;.&lt;br&gt;&lt;br&gt;The GPL family of licenses comes from the &lt;a href="https://en.wikipedia.org/wiki/Free_Software_Foundation"&gt;Free Software Foundation&lt;/a&gt;, an organization started by Richard M. Stallman after an, now famous, &lt;a href="https://en.wikipedia.org/wiki/Richard_Stallman#Events_leading_to_GNU"&gt;incident with a printer that often jammed&lt;/a&gt;.&lt;br&gt;&lt;br&gt;This family of licenses is simultaneously loved &amp;amp; frowned upon by many in the Free/Libre and OpenSource community. &lt;strong&gt;It's controversial because it requires the end user to open-source, as well as provide build and installation instructions for their project&lt;/strong&gt; if they use any GPL licensed code in it. Else, the license is quite similar to Apache-2.0. The author(s) can't be held liable, you can patent your work, redistribute and modify the original work (with a notice).&lt;br&gt;&lt;br&gt;&lt;strong&gt;The point of the license is to guarantee the &lt;/strong&gt;&lt;a href="https://en.wikipedia.org/wiki/Free_software#Definition_and_the_Four_Freedoms"&gt;&lt;strong&gt;four basic freedoms of software&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; 1. The freedom to run the program for any purpose 2. The freedom to study how the program works, and change it to make it do what you wish 3. The freedom to redistribute and make copies 4. The freedom to improve the program, and release your improvements to the public&lt;br&gt;&lt;br&gt;The GPL licenses are also known as &lt;a href="https://en.wikipedia.org/wiki/Copyleft"&gt;Copyleft licenses&lt;/a&gt; because they give permission to the end-user to freely distribute and modify the intellectual property but require that the same rights be preserved in any derivative works. Meaning that any software licensed under it preserves the four freedoms for the end-user and any of their derivative works and their end-users as well.&lt;br&gt;&lt;br&gt;&lt;strong&gt;Some people don't agree with the clause that your code has to be open-sourced.&lt;/strong&gt; This is a topic for another post. &lt;strong&gt;This boils down to the difference between free and open-source software.&lt;/strong&gt; In a nutshell, free software protects and upholds the 4 basic freedoms of software, while open-source software only makes the software's source publicly accessible. In other words, you can view the code of an open-source, but you can't necessarily use, modify, or redistribute it. While free software allows you to do what you want as long as the result is again free software.&lt;br&gt;&lt;br&gt;&lt;strong&gt;A common misconception is that this license prohibits commercial use.&lt;/strong&gt; The opposite is true, it encourages it, though you have to change your conception of commercial software. Instead of selling the compiled program, you can refocus on selling support, patches, new versions, etc. The essay "The Magic Cauldron" from the book "&lt;a href="https://en.wikipedia.org/wiki/The_Cathedral_and_the_Bazaar"&gt;The Cathedral &amp;amp; the Bazaar&lt;/a&gt;" covers the commercial aspect of free &amp;amp; open-source development in great detail, for anyone interested.&lt;br&gt;&lt;br&gt;&lt;strong&gt;The GPLv3 is best applicable for applications. This doesn't mean that it's bad for libraries!&lt;/strong&gt; Applications can greatly profit from this license as it encourages upstreaming of fixes and distribution of modifications. &lt;strong&gt;Libraries can also enjoy all the benefits that applications can&lt;/strong&gt;, but, because of it's Copyleft nature allications using such libraries must dynamically load them, which may make some end-users reluctant. It depends on your goal and beliefs whether the GPL is for your library. &lt;a href="https://www.gnu.org/licenses/why-not-lgpl.html"&gt;The FSF covers this decision in this article&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;strong&gt;The GPL has a &lt;/strong&gt;&lt;a href="https://opensource.org/licenses/LGPL-3.0"&gt;&lt;strong&gt;semi-permissive license called LGPLv3&lt;/strong&gt;&lt;/a&gt;. It's much the same as MIT, but with the limitation that prominent attribution has to be given to your project in any combined work, that all modification have to be open-sourced (under some conditions) and clearly marked. It still can force the end-user to open-source the whole combined worked if it's statically linked into the derivative, but it can be dynamically linked without repercussions.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--637c654d322e9fe15d9a0f5c2e5557699b5fba57" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZkU9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--33002893481ddb5cd55037157d487bf63be8125e/KPEKttPShuuYAn0aIXRcDQ.png" filename="KPEKttPShuuYAn0aIXRcDQ.png" filesize="175156" width="2000" height="1143" previewable="true" caption="License popularity in C &amp;amp; C++ projects, expressed as a percentage"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="License popularity in C &amp;amp; C++ projects, expressed as a percentage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQxLCJwdXIiOiJibG9iX2lkIn19--f2118bb24410eb82c588bc1cfe3207590df48784/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/KPEKttPShuuYAn0aIXRcDQ.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      License popularity in C &amp;amp; C++ projects, expressed as a percentage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;&lt;h2&gt;BSD-3-clauses&lt;/h2&gt;&lt;div&gt;&lt;a href="https://opensource.org/licenses/BSD-3-Clause"&gt;&lt;strong&gt;BSD-3-clauses&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; is a permissive license.&lt;/strong&gt; &lt;strong&gt;It's quite similar to the MIT license&lt;/strong&gt; in the sense that the author(s) hold no liability, the license has to be included in all derivative work and similarly to Apache-2.0 it &lt;strong&gt;allows a warranty to be applied&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;The biggest difference from the other licenses is that &lt;strong&gt;it forbids the end user to promote their project based on the use of the licensed project or it's author(s)&lt;/strong&gt; (without prior permission).&lt;br&gt;&lt;br&gt;Note that &lt;a href="https://opensource.org/licenses/BSDplusPatent"&gt;there exists a &lt;strong&gt;-patents&lt;/strong&gt;&amp;nbsp;version of the license&lt;/a&gt; which grants the end user the ability to patent their work, same as Apache 2.0. But this license is somewhat controversial after &lt;a href="https://hackernoon.com/facebooks-bsd-patents-license-and-how-it-affects-you-66088e052845"&gt;Facebook added very strict patent clauses to their BSD license in the React project&lt;/a&gt;, escalating so far that the project got re-licensed to MIT.&lt;br&gt;&lt;br&gt;&lt;strong&gt;BSD-3-clauses is excellent for both libraries and applications&lt;/strong&gt;. It's not as verbose or popular as Apache-2.0, yet it's as permissive as MIT, the biggest differentiating factor is it's restriction on marketing.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--2de468e90d0f862a7e4a012b93463932667efb0d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZkk9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--247b8b91c5ec893d3d272e10f3f43e3b3a7e9546/a4MJwC-Vb9EkCwvyD_KVmQ.png" filename="a4MJwC-Vb9EkCwvyD_KVmQ.png" filesize="89489" width="2000" height="540" previewable="true" caption="License popularity in Haskell projects, expressed as a percentage"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="License popularity in Haskell projects, expressed as a percentage" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQyLCJwdXIiOiJibG9iX2lkIn19--e53adb2e1d0ccb774b85777321c7d8227e928bd0/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/a4MJwC-Vb9EkCwvyD_KVmQ.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      License popularity in Haskell projects, expressed as a percentage
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;It's interesting that out of all the sampled languages that Haskell's most popular license is BSD. A friend of mine indicated to me that this could be caused by the fact that their project generator uses it by default.&lt;/div&gt;&lt;h2&gt;Clauses&lt;/h2&gt;&lt;div&gt;&lt;strong&gt;Software licenses can come with clauses which can change the base license&lt;/strong&gt;. The most recent example of this is the addition of &lt;a href="https://commonsclause.com/"&gt;the Commons Clause&lt;/a&gt; to some Redis Labs modules/products.&lt;br&gt;&lt;br&gt;&lt;strong&gt;Clauses are envisioned to fulfill the author's additional wishes or change some behavior of the base license.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;Perhaps the most well known example is GPL and LGPL. Where LGPL is basically a clause, and requires, the full GPL.&lt;/div&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;div&gt;Picking a license is hard. It boils down to what you want other people to do with your work.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If you really don't care and just want to get your project out there for people to use however they wish&lt;/strong&gt;, even if they choose to sell basically your project — then MIT is for you.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If you also want to grant your users the ability to make patents off your derivative work&lt;/strong&gt;, Apache-2.0 is the way to go.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If in addition you want to protect the authors'/contributors' privacy and control how people can use your project to market theirs&lt;/strong&gt;, then BSD is the choice for you.&lt;br&gt;&lt;br&gt;&lt;strong&gt;If you want to protect the users' freedom or you want your project to forever stay free &amp;amp; open-source&lt;/strong&gt;. Then the GPL family is your best choice.&lt;br&gt;&lt;br&gt;&lt;strong&gt;I usually use the MIT license only for libraries&lt;/strong&gt; and GPLv3 for applications, as I don't want to force people to open-source their projects, but after doing this research I think I'll choose LGPL for my Ruby library. In my opinion it's a good compromise between staying free &amp;amp; open-source while still giving the user the ability not to open-source their work.&lt;br&gt;&lt;br&gt;While reasearching this topic I've found a hand full of useful resources to do quick checks, they can't really replace reading the license text, but they give an insightful overview:&lt;br&gt;&lt;br&gt;I have also stumbled upon a few hilarious (yet functional) licenses:&lt;br&gt;&lt;br&gt;The data used to generate the statistics can be found here. It was gathered on the 25th of April, 2018 — it shouldn't be affected by the recent migration of projects from GitHub after the acquisition from Microsoft.&lt;/div&gt;&lt;h2&gt;Epilogue&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;&lt;a href="https://opensource.org/licenses/alphabetical"&gt;https://opensource.org/licenses/alphabetical&lt;/a&gt; — list of all known licenses&lt;/li&gt;&lt;li&gt;&lt;a href="https://tldrlegal.com/"&gt;https://tldrlegal.com/&lt;/a&gt; — quick bullet-point overview of most licenses&lt;/li&gt;&lt;li&gt;&lt;a href="https://choosealicense.com/"&gt;https://choosealicense.com/&lt;/a&gt; — license picking decision tree&lt;/li&gt;&lt;li&gt;&lt;a href="https://spdx.org/licenses/Beerware.html"&gt;BeerWare&lt;/a&gt; — If you think the software is good, and you meet the author, you can repay them with beer&lt;/li&gt;&lt;li&gt;&lt;a href="http://www.wtfpl.net/"&gt;Do What the Fuck You Want&lt;/a&gt; — the name says it all...&lt;/li&gt;&lt;li&gt;&lt;a href="https://spdx.org/licenses/Unlicense.html"&gt;The Unlicense&lt;/a&gt; — ironically enough, it's a license that aims to abolish any license and copyright clauses&lt;/li&gt;&lt;/ul&gt;
&lt;/div&gt;
</content>
    <summary type="html">Recently I've started working on a small Ruby library. While I was sketching the architecture of it to I was listening to some lectures from Richard M. Stallman which got me thinking about how I should license my library.

Note: I'm by no means a legal expert. Everything written here is what I've...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/1</id>
    <published>2018-10-06T15:00:00Z</published>
    <updated>2024-03-22T14:21:58Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/do-you-really-need-websockets-huEURtr2Oclt"/>
    <title>Do you really need WebSockets?</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;People mostly use WebSockets whenever they need a real-time web component. Turns out WebSockets aren't the best solution for everything.&lt;br&gt;Over the years I've had this conversation a couple of times.&lt;/div&gt;&lt;div&gt;People use WebSockets without knowing what they are, why they use them or what alternatives exist.&lt;br&gt;Existing alternatives provide advantages such as universal compatibility, simple implementations and low upkeep.&lt;br&gt;This talk will give you an overview of how WebSockets and the alternatives work, and when you should use one or the other.&lt;br&gt;&lt;a href="https://stanko.io/do-you-really-need-websockets-343aed40aa9b"&gt;Article&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">People mostly use WebSockets whenever they need a real-time web component. Turns out WebSockets aren't the best solution for everything.
Over the years I've had this conversation a couple of times.
People use WebSockets without knowing what they are, why they use them or what alternatives...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/11</id>
    <published>2018-05-03T12:00:00Z</published>
    <updated>2024-03-22T14:12:43Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/http2-rb-TwXtcnCN2yxI"/>
    <title>HTTP2.rb</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Why aren't all Rack apps served over HTTP 2 by now? What exactly is HTTP 2 and is it better than HTTP 1.1? How can you use it in Ruby?&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Why aren't all Rack apps served over HTTP 2 by now? What exactly is HTTP 2 and is it better than HTTP 1.1? How can you use it in Ruby?</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/4</id>
    <published>2018-03-29T00:00:00Z</published>
    <updated>2026-03-09T08:17:43Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/do-you-really-need-websockets-343aed40aa9b"/>
    <title>Do you really need WebSockets?</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0Nj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--d47729820c74998bfca0d740fd94b7bec83e8f6b" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZlk9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--e0b5d22bc0800e509ed27c72f44350776cae5115/cloud.png" filename="cloud.png" filesize="29190" width="2000" height="800" previewable="true" presentation="gallery" caption="The Cloud was made by Fabián Alexis (CC BY-SA 3.0), via Wikimedia Commons"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The Cloud was made by Fabián Alexis (CC BY-SA 3.0), via Wikimedia Commons" loading="lazy" style="aspect-ratio:2000 / 800;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ2LCJwdXIiOiJibG9iX2lkIn19--36e81d7bc30f721d79b9e24366125395f27fe40c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/cloud.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Cloud was made by Fabián Alexis (CC BY-SA 3.0), via Wikimedia Commons
  &lt;/figcaption&gt;
&lt;/figure&gt;Over the years I've had this conversation a couple of times. This post will explain why we use WebSockets, how they can be used, what alternatives exist and when to use them.&lt;br&gt;&lt;br&gt;Every time I worked on a project where we had to implement any kind of a "real-time" component, usually a chat or an event feed, the word WebSockets started to circulate. Though, most people use them because they either aren't aware of the alternatives, or they blindly follow other people's examples.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Why WebSockets?&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;WebSockets enable the server and client to send messages to each other at any time&lt;/strong&gt;&lt;/b&gt;, after a connection is established, without an explicit request by one or the other. This is in contrast to HTTP, which is traditionally associated with the challenge-response principle — where to get data one has to explicitly request it. In more technical terms, WebSockets enable a &lt;a href="https://en.wikipedia.org/wiki/Duplex_(telecommunications)#Full_duplex"&gt;full-duplex connection&lt;/a&gt; between the client and the server.&lt;br&gt;&lt;br&gt;In a challenge-response system there is no way for clients to know when new data is available for them (except by asking the server periodically — &lt;a href="https://en.wikipedia.org/wiki/Polling_(computer_science)"&gt;polling&lt;/a&gt; or &lt;a href="https://en.wikipedia.org/wiki/Push_technology#Long_polling"&gt;long polling&lt;/a&gt;), &lt;b&gt;&lt;strong&gt;with Websockets the server can push new data at any time which makes them the better candidate for "real-time" applications.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;It's important to note that WebSockets convert their HTTP connection to a WebSocket connection.&lt;/strong&gt;&lt;/b&gt; In other words, a WebSocket connection uses HTTP only to do the initial handshake (for authorization and authentification), after which the TCP connection is utilized to send data via the &lt;a href="https://tools.ietf.org/html/rfc6455#section-5.2"&gt;its own protocol&lt;/a&gt;.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0Mz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--66d9d89dc2be9575c46aef48497beacc7323484c" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZk09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--f4269b2891a266fb4dedc5bb0372c6caaba05e11/v2XywM5pSK_f5VZyWDMPoQ.gif" filename="v2XywM5pSK_f5VZyWDMPoQ.gif" filesize="224261" width="1000" height="200" previewable="true" presentation="gallery" caption="Animation of a WebSocket connection being established"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a WebSocket connection being established" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQzLCJwdXIiOiJibG9iX2lkIn19--94071c55e37c8c6a8bb0430d1e5262c67f3d373c/v2XywM5pSK_f5VZyWDMPoQ.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a WebSocket connection being established
  &lt;/figcaption&gt;
&lt;/figure&gt;WebSockets are a part of &lt;a href="https://www.w3.org/TR/websockets/"&gt;the HTML5 spec&lt;/a&gt; and they are supported by &lt;a href="https://caniuse.com/#feat=websockets"&gt;all modern browsers&lt;/a&gt; (meaning, there is a JS API to use them natively in the browser). They provide a mechanism to detect dropped (disconnected) clients and can handle up to a 1024 connections per browser, &lt;b&gt;&lt;strong&gt;though &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.nginx.com/blog/websocket-nginx/"&gt;&lt;b&gt;&lt;strong&gt;they aren't compatible with most load balancers out-of-the-box&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt; and have no re-connection handling mechanism.&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;pre data-language="javascript"&gt;// Create WebSocket connection.&lt;br&gt;const socket = new WebSocket('ws://localhost:8080');&lt;br&gt;&lt;br&gt;// Connection opened&lt;br&gt;socket.addEventListener('open', function (event) {&lt;br&gt;    socket.send('Hello Server!');&lt;br&gt;});&lt;br&gt;&lt;br&gt;// Listen for messages&lt;br&gt;socket.addEventListener('message', function (event) {&lt;br&gt;    console.log('Message from server ', event.data);&lt;br&gt;});&lt;/pre&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;The most common example for WebSockets is either a chat or push notifications. They can be used for those applications, but present an overkill solution to the problem&lt;/strong&gt;&lt;/b&gt;, since in those applications only the server needs to push data to the clients, and not the other way around — only a half-duplex connection is needed.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;In Ruby, there are a few gems that add WebSockets to your web app.&lt;/strong&gt;&lt;/b&gt; The one I've mostly used is Faye, though I've been looking at websocket-ruby lately. Rails supports them out-of-the-box since version 5 through ActionCable.&lt;/p&gt;&lt;pre data-language="ruby"&gt;require 'faye/websocket'&lt;br&gt;&lt;br&gt;App = lambda do |env|&lt;br&gt;  if Faye::WebSocket.websocket?(env)&lt;br&gt;    ws = Faye::WebSocket.new(env)&lt;br&gt;&lt;br&gt;    ws.on :message do |event|&lt;br&gt;      ws.send(event.data)&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    ws.on :close do |event|&lt;br&gt;      p [:close, event.code, event.reason]&lt;br&gt;      ws = nil&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    # Return async Rack response&lt;br&gt;    ws.rack_response&lt;br&gt;&lt;br&gt;  else&lt;br&gt;    # Normal HTTP request&lt;br&gt;    [200, {'Content-Type' =&amp;gt; 'text/plain'}, ['Hello']]&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;In my opinion, &lt;b&gt;&lt;strong&gt;all of those implementations are more-or-less the same — you can't really go wrong.&lt;/strong&gt;&lt;/b&gt; Note that some gems also require their own JS library (mostly to encode and decode data that's being sent or received).&lt;/p&gt;&lt;h2&gt;Server-sent events&lt;/h2&gt;&lt;p&gt;From my experience, most people don't know that regular old &lt;b&gt;&lt;strong&gt;HTTP provides a mechanism to push data from the server to clients via &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://en.wikipedia.org/wiki/Server-sent_events"&gt;&lt;b&gt;&lt;strong&gt;Server-Sent Events&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt; (aka. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events"&gt;EventSources&lt;/a&gt;).&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Server-Sent Events utilize a regular HTTP octet streams&lt;/strong&gt;&lt;/b&gt;, and therefore are limited to the browser's connection &lt;a href="https://stackoverflow.com/questions/985431/max-parallel-http-connections-in-a-browser"&gt;pool limit of ~6 concurrent HTTP connections per server&lt;/a&gt;. But &lt;b&gt;&lt;strong&gt;they provide a standard way of pushing data from the server to the clients over HTTP&lt;/strong&gt;&lt;/b&gt;, which load balancers and proxies understand out-of-the-box. The biggest advantage being that, exactly as WebSockets, they &lt;b&gt;&lt;strong&gt;utilize only one TCP connection&lt;/strong&gt;&lt;/b&gt;. The biggest disadvantage is that &lt;b&gt;&lt;strong&gt;Server-Sent Events don't provide a mechanism to detect dropped clients&lt;/strong&gt;&lt;/b&gt; until a message is sent.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0ND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ad92b2e455e5469936a3449c9a922bc439e142cc" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZlE9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--00c39a62c6ef4dfd4f513ec6caf111c4adfa6ce4/K2J4QncY_0I3gunNKXq2_g.gif" filename="K2J4QncY_0I3gunNKXq2_g.gif" filesize="174301" width="1000" height="200" previewable="true" presentation="gallery" caption="Animation of a Server-Sent Events connection being established"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a Server-Sent Events connection being established" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ0LCJwdXIiOiJibG9iX2lkIn19--0ae1f11b16d7bcd9b21190813edbb4ce72214c17/K2J4QncY_0I3gunNKXq2_g.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a Server-Sent Events connection being established
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events"&gt;They are standardized via HTML 5&lt;/a&gt;, most HTTP servers support them out-of-the-box, and &lt;a href="https://caniuse.com/#search=server%20sent%20events"&gt;they are available in most browsers except for Internet Explorer and Edge&lt;/a&gt; where they are &lt;a href="https://github.com/Yaffle/EventSource"&gt;available through a polyfill&lt;/a&gt;.&lt;br&gt;&lt;br&gt;&lt;a href="http://edgeapi.rubyonrails.org/classes/ActionController/Live/SSE.html"&gt;&lt;b&gt;&lt;strong&gt;Rails&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;, &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://github.com/jeremyevans/roda/blob/master/lib/roda/plugins/streaming.rb"&gt;&lt;b&gt;&lt;strong&gt;Roda&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt; and &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://gist.github.com/rkh/1476463"&gt;&lt;b&gt;&lt;strong&gt;Sinatra&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt; support them out-of-the-box.&lt;/strong&gt;&lt;/b&gt; And they have a simple protocol — payloads are just prefixed with one of the following keywords &lt;b&gt;&lt;strong&gt;data:&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;event:&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;id:&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;retry:&lt;/strong&gt;&lt;/b&gt;. &lt;b&gt;&lt;strong&gt;data:&lt;/strong&gt;&lt;/b&gt; is used to push a payload, &lt;b&gt;&lt;strong&gt;event:&lt;/strong&gt;&lt;/b&gt; is optional and indicates the type of data being pushed, &lt;b&gt;&lt;strong&gt;id:&lt;/strong&gt;&lt;/b&gt; is also optional and indicates the event's ID, finally &lt;b&gt;&lt;strong&gt;retry:&lt;/strong&gt;&lt;/b&gt; instructs the client to change it's connection retry timeout — &lt;b&gt;&lt;strong&gt;unlike WebSockets, Server-Sent Events have a reconnect mechanism built-in&lt;/strong&gt;&lt;/b&gt;, though this is a feature that most WebSocket libraries add any way.&lt;p&gt;&lt;/p&gt;&lt;pre data-language="ruby"&gt;# frozen_string_literal: true&lt;br&gt;&lt;br&gt;class App &amp;lt; Roda&lt;br&gt;  QUEUES = []&lt;br&gt;&lt;br&gt;  Thread.new do&lt;br&gt;    loop do&lt;br&gt;      sleep(60)&lt;br&gt;      QUEUES.each { |q| q &amp;lt;&amp;lt; { heartbeat: true } }&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  plugin :streaming&lt;br&gt;  plugin :render, engine: 'slim'&lt;br&gt;&lt;br&gt;  route do |r|&lt;br&gt;    r.root do&lt;br&gt;      view('root', layout: false)&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    r.on 'messages' do&lt;br&gt;      r.post do&lt;br&gt;        name = r.params['name']&lt;br&gt;        message = r.params['message']&lt;br&gt;        object = { name: name, message: message }&lt;br&gt;        QUEUES.each { |q| q &amp;lt;&amp;lt; object }&lt;br&gt;        object.to_json&lt;br&gt;      end&lt;br&gt;    end&lt;br&gt;&lt;br&gt;    r.get 'stream' do&lt;br&gt;      response['Content-Type'] = 'text/event-stream;charset=UTF-8'&lt;br&gt;      q = Queue.new&lt;br&gt;      QUEUES &amp;lt;&amp;lt; q&lt;br&gt;      q &amp;lt;&amp;lt; { heartbeat: true }&lt;br&gt;      stream(loop: true, callback: proc { QUEUES.delete(q) }) do |out|&lt;br&gt;        loop do&lt;br&gt;          out &amp;lt;&amp;lt; "data: #{q.pop.to_json}\n\n"&lt;br&gt;        end&lt;br&gt;      end&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;In the example above, &lt;b&gt;&lt;strong&gt;to send data to other clients a regular HTTP POST request is made&lt;/strong&gt;&lt;/b&gt; to the server. A heartbeat is kept to keep the connections alive (WebSockets also do that) and to detect dropped clients (since dropped connections can only be detected when data is pushed to them).&lt;/p&gt;&lt;h2&gt;Long polling&lt;/h2&gt;&lt;p&gt;Before there were Server-Sent Events people usually resorted to long polling to receive "real-time" data from the server. Not to give the wrong impression, long polling is still used today in some scenarios.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Long polling utilizes regular HTTP requests&lt;/strong&gt;&lt;/b&gt;. When a request is made to the server it responds immediately if there is data that can be served. If no data is available, the server drags out its response while the client waits. If in that time new data becomes available it's served to the client. When the client receives data, or its request times-out, it immediately makes a new request to re-establish the connection.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0NT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--d25838f2dd39ca3c9eb54f1e99190ea37dfaf45b" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZlU9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9217006fd4a519baa1d425b43d1b179baf777d76/PIAkRkmPMripwaFMz83nZQ.gif" filename="PIAkRkmPMripwaFMz83nZQ.gif" filesize="200797" width="1000" height="200" previewable="true" presentation="gallery" caption="Animation of a Long Polling connection being established"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a Long Polling connection being established" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ1LCJwdXIiOiJibG9iX2lkIn19--e471b1c90398ea2a870b61f02c66c8aa5cfad5e4/PIAkRkmPMripwaFMz83nZQ.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a Long Polling connection being established
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;Long polling's biggest advantage is the fact that it works in every environment&lt;/strong&gt;&lt;/b&gt;, and in every browser. &lt;b&gt;&lt;strong&gt;It's, arguably, better for applications with large numbers of concurrent users&lt;/strong&gt;&lt;/b&gt; because it doesn't require a constant TCP connection to the server (it's harder to starve the server of TCP connections since they are periodically released). &lt;b&gt;&lt;strong&gt;Though they come with the overhead of having to re-authenticate and re-authorize the client on each request&lt;/strong&gt;&lt;/b&gt;. And the server needs to implement some kind of event aggregation to overcome blackouts between re-connects. &lt;b&gt;&lt;strong&gt;There are no JS APIs available for this mechanism, there is no re-connection handling, nor dropped client detection.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;If data needs to be sent to the server a regular HTTP POST request is made, the same as with Server-Sent Events.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;When it comes to pushing data from the server to clients both WebSockets and Server-Sent Event will do the job.&lt;/strong&gt;&lt;/b&gt; There are some subtle differences and incompatibilities, but there are libraries that solve those issues.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;If you don't need to send data to the server in "real-time" (e.g. voice/video chat, multiplayer games, …) go with Server-Sent Events.&lt;/strong&gt;&lt;/b&gt; They are the standard HTTP way of pushing data (like notifications, messages or events) to clients. And &lt;b&gt;&lt;strong&gt;they can be added just by implementing an additional endpoint in a controller&lt;/strong&gt;&lt;/b&gt;, lowering the need to refactor the existing code base (in a well structured code base the "implementation cost" of both WS and SSE is the same), and making them somewhat faster to implement (and bring the feature to market).&lt;br&gt;&lt;br&gt;I don't have experience with long polling on projects with large numbers of concurrent users, so I can't attest the claims I've read about it being the better solution for those kinds of applications. Though, while we were &lt;a href="https://stanko.io/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5"&gt;reverse engineering Facebook's Messenger to add PGP to it&lt;/a&gt; I noticed that they utilize long polling to fetch messages. &lt;b&gt;&lt;strong&gt;On smaller projects I would recommend SSE over Long polling since it's easier to implement on both the server and client side.&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;If you are already using WebSockets or Long polling, don't go and convert them to Server-Sent Events. &lt;b&gt;&lt;strong&gt;All solutions are basically the same when it comes to pushing data from the server to clients.&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;&lt;figure class="lexxy-content__table-wrapper"&gt;&lt;table&gt;&lt;tbody&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;/th&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;WebSockets&lt;/p&gt;&lt;/th&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Server Sent Events&lt;/p&gt;&lt;/th&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Long Polling&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Number of parallel connections from the browser&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;1024&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;~6 per server&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;~6 per server&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Load balancing and proxying&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;Requires additional configuration&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;Out-of-the-box&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;Out-of-the-box&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Supported on all browsers&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;Yes (90%)&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;No (84%)&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;Yes (100%)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Dropped client detection&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;Yes&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;No&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;No&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Reconnection handling&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;No&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;Yes&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;No&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Over the years I've had this conversation a couple of times. This post will explain why we use WebSockets, how they can be used, what alternatives exist and when to use them.

Every time I worked on a project where we had to implement any kind of a "real-time" component, usually a chat or an...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/5</id>
    <published>2018-03-07T00:00:00Z</published>
    <updated>2026-03-09T08:19:30Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb"/>
    <title>RabbitMQ is more than a Sidekiq replacement</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a25798203fc7d53574aea8d7ab664ea477480854" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ1FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--922002215b110196418c13134ab77457dc66b2c2/rabbit_karate.png" filename="rabbit_karate.png" filesize="123940" width="2160" height="1000" previewable="true" presentation="gallery" caption="A rabbit dressed up as the karate kid - a cross of RabbitMQ and Sidekiq logos"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A rabbit dressed up as the karate kid - a cross of RabbitMQ and Sidekiq logos" loading="lazy" style="aspect-ratio:2160 / 1000;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjYwLCJwdXIiOiJibG9iX2lkIn19--ab73c1736a736f2f85addee81ee655e10ef77f4c/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/rabbit_karate.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A rabbit dressed up as the karate kid - a cross of RabbitMQ and Sidekiq logos
  &lt;/figcaption&gt;
&lt;/figure&gt;I've had gripes with &lt;a href="https://github.com/mperham/sidekiq"&gt;Sidekiq&lt;/a&gt; because of which I switched to RabbitMQ. Here are my thoughts and experiences after a year of using it in production.&lt;br&gt;&lt;br&gt;I got inspired to write this post by the overwhelming response I received for my &lt;a href="https://github.com/stankec/lectures/tree/master/19-rabbitmq_is_more_than_a_sidekiq_replacement"&gt;talk&lt;/a&gt; at the &lt;a href="https://www.meetup.com/rubyzg/"&gt;local Ruby user group&lt;/a&gt;.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Why do we need Sidekiq or RabbitMQ?&lt;/h2&gt;&lt;p&gt;"Background job" libraries like Sidekiq are used in situations when &lt;b&gt;&lt;strong&gt;the result of a process can be yielded, but further side effects still need to be caused.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;An example of this are signup forms. When a user signs up, usually, a confirmation email is sent out to confirm their email, but the email is not crucial to the signup process — the user's account will be created even if the confirmation email fails to get sent.&lt;br&gt;&lt;br&gt;It's unclear what to do if the email fails to get sent. The intuitive solution would be to return an error, but then the user wouldn't be able to create a new account since their email is already taken. &lt;b&gt;&lt;strong&gt;We are penalizing the user for something that isn't their fault&lt;/strong&gt;&lt;/b&gt;, something they can't influence and something that has no effect on the action they wanted to undertake. If we decide to retry the action we end up with the same problem as before if it fails again.&lt;br&gt;&lt;br&gt;To resolve those issues we offload that work to a "background job" library. In the case of the signup example, the user's account would be created and they would be logged in. The "send email" job would be put in a queue, and would eventually get processed. If the job fails we can retry it as many times as we like, or run custom logic on the raised errors. &lt;b&gt;&lt;strong&gt;The user isn't penalized for our mistake.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;But do we really need Sidekiq for this?&lt;/strong&gt;&lt;/b&gt; No. The same functionality can be accomplished with the standard &lt;a href="http://ruby-doc.org/core-2.5.0/Queue.html"&gt;Queue class&lt;/a&gt; and a &lt;a href="http://ruby-doc.org/core-2.5.0/Thread.html"&gt;Thread&lt;/a&gt;.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class App &amp;lt; Roda&lt;br&gt;  JOBS = Queue.new&lt;br&gt;&lt;br&gt;  Thread.new do&lt;br&gt;    loop do&lt;br&gt;      begin&lt;br&gt;        job = JOBS.pop&lt;br&gt;        job.call&lt;br&gt;      rescue =&amp;gt; e&lt;br&gt;        puts "ERROR: #{e}"&lt;br&gt;        JOBS &amp;lt;&amp;lt; job&lt;br&gt;      end&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;&lt;br&gt;  route do |r|&lt;br&gt;    r.on 'sign_up' do&lt;br&gt;      r.post do&lt;br&gt;        email = r.params['email']&lt;br&gt;        JOBS &amp;lt;&amp;lt; proc do&lt;br&gt;          Mailer::ConfirmationMailer.deliver(email)&lt;br&gt;        end&lt;br&gt;      end&lt;br&gt;    end&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;h2&gt;Why do we use background workers then?&lt;/h2&gt;&lt;p&gt;The above approach has many downsides. Not to go too deep down the rabbit hole, I'll focus on &lt;b&gt;&lt;strong&gt;debuggability&lt;/strong&gt;&lt;/b&gt; (yes, I made that word up), &lt;b&gt;&lt;strong&gt;persistence&lt;/strong&gt;&lt;/b&gt;, &lt;b&gt;&lt;strong&gt;scaling&lt;/strong&gt;&lt;/b&gt; and lastly &lt;b&gt;&lt;strong&gt;fault tolerance&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;The above solution is difficult to debug. There is no clear way to inspect the contents of the queue without littering the code with &lt;a href="https://github.com/pry/pry"&gt;bindings to pry&lt;/a&gt;. If the server is stopped (e.g. to add said bindings to pry) all jobs in the queue are lost, which means that we can't even extract the job that caused the issue to replicate it. &lt;b&gt;&lt;strong&gt;To solve those issues Sidekiq uses &lt;/strong&gt;&lt;/b&gt;&lt;a href="https://redis.io/"&gt;&lt;b&gt;&lt;strong&gt;Redis&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt; to store it's jobs.&lt;/strong&gt;&lt;/b&gt; Redis is an in-memory key-value store that can store values as many different data types. Jobs are stored in a list as JSON objects. We can inspect the queue's content by connecting to Redis with redis-cli and inspecting the queue list.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--5839e91d9f4dc729defc4665d903966b42be4a4b" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ1VCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--2eafd87dd6dfdd9c7e138b8b7489e49f8df588ed/CsKEeXDBB7evp8X129dVWw.png" filename="CsKEeXDBB7evp8X129dVWw.png" filesize="130263" width="1000" height="238" previewable="true" presentation="gallery" caption="Contents of the default queue in Redis"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Contents of the default queue in Redis" loading="lazy" style="aspect-ratio:1000 / 238;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjYxLCJwdXIiOiJibG9iX2lkIn19--e32712275e0dd902de8c5107bb8d82f76d5c63b7/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/CsKEeXDBB7evp8X129dVWw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Contents of the default queue in Redis
  &lt;/figcaption&gt;
&lt;/figure&gt;Using Redis as a central job queue also enables us to scale the number of workers to accommodate higher workloads.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Sidekiq's memory problem&lt;/h2&gt;&lt;p&gt;There are two concerns regarding fault tolerance, Redis and the worker process. By default, &lt;b&gt;&lt;strong&gt;Redis is a volatile store&lt;/strong&gt;&lt;/b&gt; (data may be lost if the store restarts), though that can be changed by utilizing its &lt;a href="https://redis.io/topics/persistence"&gt;RDB and AOF features&lt;/a&gt; which in conjunction prevent any data loss. There are some caveats, if RDB (which is enabled by default) is used without AOF, data loss may still occur, and if used in conjunction with AOF may cause performance fluctuations.&lt;br&gt;&lt;br&gt;When it comes to worker failures, Sidekiq handles most issues regarding handling Ruby errors and retry logic. &lt;b&gt;&lt;strong&gt;But if a worker crashes or is killed while processing a job, that job is gone.&lt;/strong&gt;&lt;/b&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0OD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--3b68d9c19bbd7725e1010629d9095aba580fe721" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZmc9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--518d48d1bac4edc981f9d9fd5100550c67985df3/pN5LdFeiowAimYZFQEmmEg.gif" filename="pN5LdFeiowAimYZFQEmmEg.gif" filesize="74898" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of how a job can disappear from Sidekiq in the case of a worker restart/crash"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of how a job can disappear from Sidekiq in the case of a worker restart/crash" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ4LCJwdXIiOiJibG9iX2lkIn19--ed7cbf351f1c22a79e50e01ddf5d68cd28145af3/pN5LdFeiowAimYZFQEmmEg.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of how a job can disappear from Sidekiq in the case of a worker restart/crash
  &lt;/figcaption&gt;
&lt;/figure&gt;The most common cases for job disappearance are &lt;b&gt;&lt;strong&gt;deploys&lt;/strong&gt;&lt;/b&gt; (since the application is killed at that point, though Sidekiq offers rolling restarts with it's Pro plan) and &lt;b&gt;&lt;strong&gt;VM crashes&lt;/strong&gt;&lt;/b&gt; caused by faulty gem extensions (as they can't be caught).&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0OT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--e1ce75965007e35f404e2afab704f1b6ab4bb8b0" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZms9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--841915a88ce25e393f98c41ee1f26729652db817/ildByqYgDM_br_4vv1YG5w.png" filename="ildByqYgDM_br_4vv1YG5w.png" filesize="58156" width="1000" height="153" previewable="true" presentation="gallery" caption="Screenshot of Sidekiq's README about error handeling"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Screenshot of Sidekiq's README about error handeling" loading="lazy" style="aspect-ratio:1000 / 153;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ5LCJwdXIiOiJibG9iX2lkIn19--357d5b02c6c0cdc7ee53ae6459a11e4e93ca8b97/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/ildByqYgDM_br_4vv1YG5w.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Screenshot of Sidekiq's README about error handeling
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;If you are a business, or have a $1000 spare, you can resolve this issue by buying a &lt;a href="https://sidekiq.org/products/pro.html"&gt;Sidekiq Pro license&lt;/a&gt; which ensures that a job gets executed and comes with other niceties. Personally, I find this price tag too steep (especially since it's recurring) for small/personal projects which lead me to look for alternatives.&lt;br&gt;&lt;br&gt;If we ignore the fault tolerance pay wall, Sidekiq still has a memory consumption issue as it uses Redis for its job queue. Redis, is an in-memory data store, and it has no mechanism to offload stale data to disk. This means that &lt;b&gt;&lt;strong&gt;all jobs in the queue are kept in-memory all the time&lt;/strong&gt;&lt;/b&gt;.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--561c5f1f5512cde92f21845e9e12dac174cc8918" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZm89IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--555e20d335ba653c40071d42d1d9d17c91fa4e80/hZcnzhMtLzR6XBuI5XL7Tw.png" filename="hZcnzhMtLzR6XBuI5XL7Tw.png" filesize="43079" width="1000" height="555" previewable="true" presentation="gallery" caption="Redis memory usage plot around peak usage times"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Redis memory usage plot around peak usage times" loading="lazy" style="aspect-ratio:1000 / 555;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjUwLCJwdXIiOiJibG9iX2lkIn19--24da4cb8d111f0884b9c4468b0189503684f1390/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/hZcnzhMtLzR6XBuI5XL7Tw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Redis memory usage plot around peak usage times
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;The most common solution to this issue is passing IDs of database records instead of values to the job queue. &lt;b&gt;&lt;strong&gt;This reduces Redis' memory consumption, but increases Sidekiq's or Ruby's&lt;/strong&gt;&lt;/b&gt; since each Sidekiq instance needs to initialize your application to get access to your models. This can be solved by writing your workers as lightweight Ruby processes, but now we have the issue of managing models and database access information in two separate applications (your worker and your main application). Another solution is to consume your main app's API, but then we are increasing load on our app instead of off-loading work from it.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;How RabbitMQ solves those issues&lt;/h2&gt;&lt;p&gt;RabbitMQ is a general purpose message queue. To utilize it in a "background worker" backend scenario we need a library to communicate with it. I would highly recommend &lt;a href="https://github.com/jondot/sneakers"&gt;Sneakers&lt;/a&gt; for this purpose. Sneakers handles worker process creation, management, queue creation, and job enqueuing — everything that Sidekiq does, and offers a syntax that resembles Sidekiq's syntax.&lt;/p&gt;&lt;pre data-language="ruby"&gt;class SneakersLogger&lt;br&gt;  include Sneakers::Worker&lt;br&gt;&lt;br&gt;  # Defines the queue and it's options&lt;br&gt;  from_queue 'loggings'&lt;br&gt;&lt;br&gt;  def work(log_message)&lt;br&gt;    Logger.log(log_message)&lt;br&gt;    # Does magic that will be explaned in the next section&lt;br&gt;    ack!&lt;br&gt;  end&lt;br&gt;end&lt;br&gt;&lt;br&gt;# ---&lt;br&gt;&lt;br&gt;class SidekiqLogger&lt;br&gt;  include Sidekiq::Worker&lt;br&gt;&lt;br&gt;  def perform(log_message)&lt;br&gt;    Logger.log(log_message)&lt;br&gt;  end&lt;br&gt;end&lt;/pre&gt;&lt;p&gt;The biggest difference between the two implementations is the &lt;b&gt;&lt;strong&gt;ack!&lt;/strong&gt;&lt;/b&gt; on line 10. &lt;b&gt;&lt;strong&gt;That line enables Sneakers and RabbitMQ to guarantee that a job has been processed.&lt;/strong&gt;&lt;/b&gt; This is a feature of RabbitMQ's communication protocol — AMQP. In AMQP a message can be popped from a queue in two modes — ack mode and no-ack mode.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f0cd928d2a2d42003935b6095e972b14db6493a9" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZnM9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--aa84f0a97f66d77e6ec8cf43034b45000395907d/yEaLpgLdxaLXE0AFCWh79g.gif" filename="yEaLpgLdxaLXE0AFCWh79g.gif" filesize="141245" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of a consumer failing and succeeding in ACK mode"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a consumer failing and succeeding in ACK mode" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjUxLCJwdXIiOiJibG9iX2lkIn19--aa9061fdbfaeaf37f78afa2106622da1d3a87f19/yEaLpgLdxaLXE0AFCWh79g.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a consumer failing and succeeding in ACK mode
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;In ack mode the consumer must specify the maximum amount of time for it to process the message.&lt;/strong&gt;&lt;/b&gt; When the consumer pops a message from the queue it's virtually removed from it, but RabbitMQ still keeps a copy of it. &lt;b&gt;&lt;strong&gt;If the consumer fails to send an "ack" signal in the specified time period the message is put back at the front of the queue so that another consumer can process it.&lt;/strong&gt;&lt;/b&gt; If the consumer sends an "ack" signal in the specified time period the message is fully removed from RabbitMQ. &lt;b&gt;&lt;strong&gt;In no-ack mode no guarantees are given&lt;/strong&gt;&lt;/b&gt;, no time window has to be specified, and no "ack" signal has to be sent.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Another difference is memory consumption.&lt;/strong&gt;&lt;/b&gt; By default, RabbitMQ keeps as many messages in memory as it can, before it reaches a configurable high water mark. At that point it offloads all eligible messages to disk. Though, that is not true for all queues. RabbitMQ also provides a "lazy queue" which keeps all it's messages on disk if possible — it's useful for passing large messages.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--59db7511b3ab4a51a455c553e0d71222bbf72cd6" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZnc9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4c36d63ec9059b758090ccf9f380384984b17f7d/Z_QyC0chosqm-pdy0jVPA.png" filename="Z_QyC0chosqm-pdy0jVPA.png" filesize="56353" width="700" height="412" previewable="true" presentation="gallery" caption="Illustration of RabbitMQ resource allocation per message in a normal and a lazy queue"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Illustration of RabbitMQ resource allocation per message in a normal and a lazy queue" loading="lazy" style="aspect-ratio:700 / 412;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjUyLCJwdXIiOiJibG9iX2lkIn19--e12d57ce69f89dbe5ade4ef4d51c40a937d610f1/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Z_QyC0chosqm-pdy0jVPA.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Illustration of RabbitMQ resource allocation per message in a normal and a lazy queue
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;b&gt;&lt;strong&gt;With those features we can create reliable job queues which can handle larger payloads.&lt;/strong&gt;&lt;/b&gt; Now we can write our workers as lightweight Ruby processes which have no database access, as they can now receive all data needed for them to process a job through the queue without a large performance or memory penalty.&lt;p&gt;&lt;/p&gt;&lt;p&gt;There is still one feature of Sidekiq I haven't mentioned — the UI. Sidekiq's UI is useful for monitoring the health of your jobs, and general throughput.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eb3a3bde33e88a337106b4a265e0318bff413cdc" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ1lCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d51940136a2bd36b1d9f9f7b88b487aac6076b20/2S8GT8C0hkpfZzL_i6CiDQ.png" filename="2S8GT8C0hkpfZzL_i6CiDQ.png" filesize="537125" width="1000" height="615" previewable="true" presentation="gallery" caption="The Sidekiq UI"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="The Sidekiq UI" loading="lazy" style="aspect-ratio:1000 / 615;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjYyLCJwdXIiOiJibG9iX2lkIn19--dab03c596b41c6f25c2468d4d3dead312b973b4a/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/2S8GT8C0hkpfZzL_i6CiDQ.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Sidekiq UI
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;b&gt;&lt;strong&gt;RabbitMQ also comes with a powerful UI&lt;/strong&gt;&lt;/b&gt; which provides insight not only into the number of failed and succeed jobs, but also into global and per-queue system resource (disk, RAM, …) usage, the ability to consume and publish messages directly from the UI, consumer and user management, and a login screen… which is premium feature in Sidekiq and costs $1950 per year (and there is a limit of 100 workers for that price).&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1Mz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c9e09951e65e196eceaf5bd0b1b7c9a679e5e8c4" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZjA9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3a4aac3b15de95da35da5011b3108e926a28cab6/ekcsvlnTnmMqu1uwOLOFUw.png" filename="ekcsvlnTnmMqu1uwOLOFUw.png" filesize="123781" width="1000" height="614" previewable="true" presentation="gallery" caption="RabbitMQ Management Console"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="RabbitMQ Management Console" loading="lazy" style="aspect-ratio:1000 / 614;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjUzLCJwdXIiOiJibG9iX2lkIn19--9c9cff9bc12db4f542233db55a28c9dad30121e3/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/ekcsvlnTnmMqu1uwOLOFUw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      RabbitMQ Management Console
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Exchanges&lt;/h2&gt;&lt;p&gt;AMQP defines the concept of exchanges. Exchanges can be thought of as routers. &lt;b&gt;&lt;strong&gt;When a message is published to an exchange, the exchange determines which queues should the message be delivered to.&lt;/strong&gt;&lt;/b&gt; It's important to note that it's impossible to put a message directly into a queue. Even if a message is published directly to a queue, a temporary exchange will be created to deliver it to the queue.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;There are four types of exchanges supported by RabbitMQ.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;The most commonly used exchange type is the &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-direct"&gt;&lt;b&gt;&lt;strong&gt;direct exchange&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;. It directly delivers all messages to a single queue bound to it. &lt;b&gt;&lt;strong&gt;It's a 1-on-1 mapping of an exchange to a queue.&lt;/strong&gt;&lt;/b&gt; If applied to a chat application, a direct exchange would deliver messages from a chat room to a single user.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1ND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--2afe1ca7d596a4ec72ee4ad41f92c3dc4ad267f9" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZjQ9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3aa98470ab147b3697c134c52cfd0d0b9572fab6/09G-3e6JMVQ5C5bkj-Cydg.gif" filename="09G-3e6JMVQ5C5bkj-Cydg.gif" filesize="31905" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of a direct exchange being utilized in a chat app"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a direct exchange being utilized in a chat app" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU0LCJwdXIiOiJibG9iX2lkIn19--35e928bd5919c1f35087cea1baeffc4437bbe0c1/09G-3e6JMVQ5C5bkj-Cydg.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a direct exchange being utilized in a chat app
  &lt;/figcaption&gt;
&lt;/figure&gt;Another kind of exchange is the &lt;b&gt;&lt;strong&gt;fan-out exchange&lt;/strong&gt;&lt;/b&gt;. They deliver messages to all queues bound to them. &lt;b&gt;&lt;strong&gt;It's a 1-to-many mapping of an exchange to multiple queues.&lt;/strong&gt;&lt;/b&gt; In the example of the chat application, a fan-out exchange would be used to send a message to all users.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1NT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--680204b6d29a38acde0c34fb90ca3eacbfc80acf" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZjg9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b3867353d7c1d70b23a38d532e5cd09f3e0d6422/2FeZqAfFqpiHAWMV0g8Rxw.gif" filename="2FeZqAfFqpiHAWMV0g8Rxw.gif" filesize="31864" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of a fan-out exchange being utilized in a chat app"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a fan-out exchange being utilized in a chat app" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU1LCJwdXIiOiJibG9iX2lkIn19--1eb9eccbb0d80b26c0980a97ad557bc464c78f20/2FeZqAfFqpiHAWMV0g8Rxw.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a fan-out exchange being utilized in a chat app
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Then there are &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-topic"&gt;&lt;b&gt;&lt;strong&gt;topic exchanges&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;. &lt;b&gt;&lt;strong&gt;They deliver messages published to them to a bound queue based on the messages tag/topic and the queue's bound topic.&lt;/strong&gt;&lt;/b&gt; In the example of the chat application, a topic exchange would be used to direct messages to their corresponding user.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1Nj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ec1d2584470806b544fc229014373ace64e95abf" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--6933694c26fbce1c718d9550c3a60fe609ff72bb/f2lzM5oK-DyYdFWfk9rexw.gif" filename="f2lzM5oK-DyYdFWfk9rexw.gif" filesize="48341" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of a topic exchange being utilized in a chat app"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of a topic exchange being utilized in a chat app" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU2LCJwdXIiOiJibG9iX2lkIn19--2a0c4fe6eac1a994708f7e61458bf1c06ce4875f/f2lzM5oK-DyYdFWfk9rexw.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of a topic exchange being utilized in a chat app
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Finally, there are &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-headers"&gt;&lt;b&gt;&lt;strong&gt;header exchanges&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;. They are a step up from topic exchanges. &lt;b&gt;&lt;strong&gt;Instead of looking at a topic, also called a routing key, they look at the message's headers to determine where a message should be delivered.&lt;/strong&gt;&lt;/b&gt; Messages in RabbitMQ can have additional attributes associated with them — called headers. Headers determine different behaviors when handling messages. E.g. an "x-match" header indicates to the exchange that either any or all headers have to match a value for it to get routed to a queue. There is also the "reply-to" header which indicates where the result of processing a message should be published to.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Utilizing exchanges gives many advantages&lt;/strong&gt;&lt;/b&gt; — exactly once delivery, performance, and ease of deprecation. Utilizing an exchange to deliver your messages is much faster and more reliable than using Ruby to handle that logic. There is also the pragmatic reason of not having code to maintain. The logic is handled by RabbitMQ, you only have to configure it (which can be done through code). Exchange-exchange and exchange-queue bindings can be changed on-the-fly by any client which enables one application to change the behavior of other services. &lt;b&gt;&lt;strong&gt;Personally, exchanges have helped me deploy applications with little-to-no downtime and deprecate services without having to change other services.&lt;/strong&gt;&lt;/b&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;&lt;b&gt;&lt;strong&gt;Special features&lt;/strong&gt;&lt;/b&gt;&lt;/h2&gt;&lt;p&gt;RabbitMQ adds it's own magic on top of AMQP. I have already mentioned header exchanges, which are a non-standard AMQP feature. A feature I personally use a lot is &lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/direct-reply-to.html"&gt;&lt;b&gt;&lt;strong&gt;direct reply-to&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;. Direct reply-to is a form of synchronous communication between a producer and a consumer. &lt;b&gt;&lt;strong&gt;It enables a producer to publish a message and wait for a consumer to process it and return the result directly to the producer.&lt;/strong&gt;&lt;/b&gt; It's useful when the result of a message is used in further processes. E.g. IoT devices usually log a heartbeat signal to their server to indicate that they are connected and configured correctly. If we have a smart lock, we can process its heartbeat asynchronously since the result isn't really important for the server nor for the device. But a pin check is important and should be handled synchronously to avoid access permission errors caused by stale data.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1Nz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--2dd1c6ce14af19e7380807402dce721d0dffbbe2" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ0VCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c0dc20c2af47dc554839cdc1eda613c35daf9ff0/XCIe9E2HlbFYxRPEIwsWRg.gif" filename="XCIe9E2HlbFYxRPEIwsWRg.gif" filesize="72709" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of utilizing a direct reply-to message"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of utilizing a direct reply-to message" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU3LCJwdXIiOiJibG9iX2lkIn19--d97518c107010c242cdc117bfb30339dc29e7467/XCIe9E2HlbFYxRPEIwsWRg.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of utilizing a direct reply-to message
  &lt;/figcaption&gt;
&lt;/figure&gt;Another feature that I often utilize is &lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/dlx.html"&gt;&lt;b&gt;&lt;strong&gt;dead lettering&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;. It allows for a message to be re-queued automatically in case it gets rejected from an exchange or queue. &lt;b&gt;&lt;strong&gt;This feature is useful for error handling&lt;/strong&gt;&lt;/b&gt;, exponential back-off, scheduled message processing …&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1OD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--bb261753cccd383b2d35bd4d755b2f841d6e2f86" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ0lCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--5ad90793b044946d7324edaf946e711461498dd3/fJWeNUxNbDgCzK162fs02Q.gif" filename="fJWeNUxNbDgCzK162fs02Q.gif" filesize="45344" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of utilizing a dead letter queue"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of utilizing a dead letter queue" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU4LCJwdXIiOiJibG9iX2lkIn19--4ddf36fe298f6e3b244750390151a1bbe503b2b3/fJWeNUxNbDgCzK162fs02Q.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of utilizing a dead letter queue
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/ae.html"&gt;&lt;b&gt;&lt;strong&gt;Alternate exchange&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt; is a useful feature for deprecating services. &lt;b&gt;&lt;strong&gt;It specifies to which exchange a message should get sent to in the case that the primary exchange rejects it.&lt;/strong&gt;&lt;/b&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI1OT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a6c5e8f8f8d2f1a3b78c04f4637cd22fa3c26399" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ01CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--e59b3ce4562efffb86ab73fa8f1e860e17286b20/OgY1IocLzrFHuPrm2FSaPg.gif" filename="OgY1IocLzrFHuPrm2FSaPg.gif" filesize="58502" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of utilizing an alternate exchange"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of utilizing an alternate exchange" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjU5LCJwdXIiOiJibG9iX2lkIn19--a93fd9719fe359fe8e237cd4b81d96f9d8d18c69/OgY1IocLzrFHuPrm2FSaPg.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of utilizing an alternate exchange
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Then there are &lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/priority.html"&gt;&lt;b&gt;&lt;strong&gt;priority queues&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt; and &lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/consumer-priority.html"&gt;&lt;b&gt;&lt;strong&gt;priority consumers&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;. Priority queue are CS standard priority queues. Meaning that messages in the queue have a priority (ranging from 0 to 255), &lt;b&gt;&lt;strong&gt;messages with higher priority get processed first&lt;/strong&gt;&lt;/b&gt;. Which is useful for the same reasons as "direct reply-to", but it's asynchronous. While &lt;b&gt;&lt;strong&gt;priority consumers are a form of fail-over&lt;/strong&gt;&lt;/b&gt;. Priority consumers will be served messages if they are active. In the case that all priority consumers are inactive, other consumers will get served messages.&lt;br&gt;&lt;br&gt;Finally there is &lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;&lt;a href="https://www.rabbitmq.com/ttl.html"&gt;&lt;b&gt;&lt;strong&gt;TTL&lt;/strong&gt;&lt;/b&gt;&lt;/a&gt;&lt;b&gt;&lt;strong&gt;"&lt;/strong&gt;&lt;/b&gt;. It specifies how long a message lives. &lt;b&gt;&lt;strong&gt;If a message outlives its TTL it's automatically rejected from the queue.&lt;/strong&gt;&lt;/b&gt; Though, this feature comes with a caveat — &lt;b&gt;&lt;strong&gt;this rule can only be enforced for the message at the front of the queue&lt;/strong&gt;&lt;/b&gt;. E.g. if you put two messages in a queue the first with a TTL of 300 and the second with a TTL of 100. Both would be in the queue until the one with a TTL 300 expires, because it is in front. The moment it expires, the second message is at the front and automatically expires since it's TTL has passed. This seems harmless, but can cause a lot of problems when combined with dead lettering to achieve e.g. offset delivery.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI0Nz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--90c9c8023ebaf8b1b9df62cce6d17fe9a48f337c" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZmM9IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b0cb479bba0b3fa60e52833db2fe822749052947/C9ic606EeV0_Sw5a4DaDpQ.gif" filename="C9ic606EeV0_Sw5a4DaDpQ.gif" filesize="64273" width="700" height="412" previewable="true" presentation="gallery" caption="Animation of TTL messages combined with dead lettering"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Animation of TTL messages combined with dead lettering" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjQ3LCJwdXIiOiJibG9iX2lkIn19--48e0f07f1603e8cd28fceec340f89d38d13ec148/C9ic606EeV0_Sw5a4DaDpQ.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Animation of TTL messages combined with dead lettering
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Plugins&lt;/h2&gt;&lt;p&gt;For me, this is the most important feature of RabbitMQ. &lt;b&gt;&lt;strong&gt;With plugins you can add any functionality you want to RabbitMQ&lt;/strong&gt;&lt;/b&gt;. The best example of this is the management console which is a plugin, and must be enabled before use.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Through plugins, RabbitMQ supports not only AMQP, but STOMP, MQTT and WebSockets as communication protocols.&lt;/strong&gt;&lt;/b&gt;&lt;br&gt;&lt;br&gt;Then there is the &lt;b&gt;&lt;strong&gt;Federation plugin&lt;/strong&gt;&lt;/b&gt;. It enables RabbitMQ to run several isolated clusters or instances which can communicate with one-another. It is similar to the way that &lt;a href="https://mastodon.social/about"&gt;Mastodon&lt;/a&gt; works. All users, no matter which server they signed up to, can communicate with one-another. &lt;b&gt;&lt;strong&gt;Federations are useful for handling large workloads.&lt;/strong&gt;&lt;/b&gt; E.g. if you handle logs from a lot of different machines through RabbitMQ, that can be handled by one federated cluster, while everything else is handled by another federated cluster. That way you can scale those two cluster independently depending on their workload, and you avoid the noisy neighbor problem (when a highly taxed service slows the whole system down).&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2Mz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--29ad575439d41f7a928bf499377c765e6dec1e9b" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ2NCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--60b5030550e808099f13021ba0be019efb34597e/wLKNGWu84jQdccQFtUgoRQ.png" filename="wLKNGWu84jQdccQFtUgoRQ.png" filesize="59617" width="700" height="427" previewable="true" presentation="gallery" caption="Example RabbitMQ Federation for an IoT application"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Example RabbitMQ Federation for an IoT application" loading="lazy" style="aspect-ratio:700 / 427;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjYzLCJwdXIiOiJibG9iX2lkIn19--74a9ee28c932844a1212b2dc113dbbfb519acfbc/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/wLKNGWu84jQdccQFtUgoRQ.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Example RabbitMQ Federation for an IoT application
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;Replacing Sidekiq with RabbitMQ provides many advantages when it comes to debuggability, scaling, fault tolerance and memory consumption.&lt;/strong&gt;&lt;/b&gt; It supports multiple industry-standard message queue protocols and can be used as a drop in replacement for other "background worker" libraries.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;If you need a queue that guarantees job execution and persistence, go with RabbitMQ instead of Sidekiq.&lt;/strong&gt;&lt;/b&gt; There are some features that are missing in Rabbit, like cron jobs and unique jobs, but they can be added by the clients. RabbitMQ offers a plethora of features which, if not useful at first, will become useful later as they will help grow a monolith to a services oriented architecture.&lt;br&gt;&lt;br&gt;To get started take a look at projects like &lt;a href="https://github.com/jondot/sneakers"&gt;Sneakers&lt;/a&gt; (background jobs) and &lt;a href="https://github.com/ruby-amqp/bunny"&gt;Bunny&lt;/a&gt; (AMQP client), read through the &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html"&gt;basic concepts page&lt;/a&gt;, and lastly there is the &lt;a href="https://www.rabbitmq.com/documentation.html"&gt;manual&lt;/a&gt;. If you are using Ruby on Rails, &lt;a href="https://github.com/jondot/sneakers/wiki/How-To:-Rails-Background-Jobs-with-ActiveJob"&gt;Sneakers integrates with ActiveJob&lt;/a&gt; which eases the transition.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;Sidekiq isn't useless!&lt;/strong&gt;&lt;/b&gt; If your project doesn't require execution or persistence guarantees, or if you hold a Sidekiq Pro license, I would recommend you stick with it for the time being. While I disagree with hiding essential features (like guaranteed execution, and rolling restarts) behind a paywall, &lt;b&gt;&lt;strong&gt;a Pro license offers support and additional business oriented features which you won't get with a self-hosted RabbitMQ instance.&lt;/strong&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">I've had gripes with Sidekiq because of which I switched to RabbitMQ. Here are my thoughts and experiences after a year of using it in production.

I got inspired to write this post by the overwhelming response I received for my talk at the local Ruby user group.

Why do we need Sidekiq or...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/3</id>
    <published>2018-03-01T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/supercharging-services-architectures-with-rabbitmq-b2dc75804577"/>
    <title>Supercharging services architectures with RabbitMQ</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2ND9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c1607fbf54d43cc16df9e6802418553e82d1c972" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ2dCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--390cfb28f313a1b0884a788d5431b13f4e891308/bunny.png" filename="bunny.png" filesize="102728" width="2160" height="860" previewable="true" caption="A rabbit with a tachometer in it's silhouette"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="A rabbit with a tachometer in it's silhouette" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY0LCJwdXIiOiJibG9iX2lkIn19--5466cd017327c96e3e44505f523243405b0fef10/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/bunny.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A rabbit with a tachometer in it's silhouette
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;When I first started using RabbitMQ I didn't understand its usefulness beyond a job queue, but it's helped me to grow and manage services architectures without headaches.&lt;/div&gt;&lt;h2&gt;Services Architecture&lt;/h2&gt;&lt;div&gt;In web development, a services architecture describes a single application that consists of multiple, smaller, highly specialized, loosely coupled applications that each independently, or in tandem, solve one part of the business domain.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2OT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--9b6e74c1405acfbb8e3bc8388876548c875a2d1a" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZzBCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b4a90ebe1ed6d868ace47de95222a7a02ad3ca48/zDwh70Y-0ntatt7hwH8cpw.png" filename="zDwh70Y-0ntatt7hwH8cpw.png" filesize="59895" width="1000" height="452" previewable="true" caption="An example of a services architecture app"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="An example of a services architecture app" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY5LCJwdXIiOiJibG9iX2lkIn19--59daceaa72a2b8c8b94beb7e5c290f9ba7f32450/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/zDwh70Y-0ntatt7hwH8cpw.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      An example of a services architecture app
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;This architecture allows for &lt;strong&gt;parallel development of features&lt;/strong&gt; (multiple teams can work on different services, each implementing one part of the business domain), &lt;strong&gt;continuous delivery&lt;/strong&gt; of large applications without downtime (the application, as a whole, should partially work even with one service down), and the &lt;strong&gt;freedom to experiment and innovate&lt;/strong&gt; on the tech stack (a service can be implemented in any technology, framework or language).&lt;br&gt;&lt;br&gt;A services architecture introduces a new challenge — &lt;strong&gt;working with a distributed system.&lt;/strong&gt;&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;Problems&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;The most common problem is state management or &lt;strong&gt;data consistency&lt;/strong&gt;. &lt;strong&gt;RabbitMQ doesn't tackle this problem at all.&lt;/strong&gt; Your application now consists of multiple smaller applications, each of which can have their own state and/or database — on which another service can rely on (e.g. a mailer service needs a user's email from an authentication service). This &lt;strong&gt;causes consistency issues&lt;/strong&gt;. There are many available solutions to this problem, like &lt;a href="https://www.martinfowler.com/bliki/CQRS.html"&gt;CQRS&lt;/a&gt;, but this topic won't be covered here.&lt;br&gt;&lt;br&gt;The other common problem is &lt;strong&gt;service discovery&lt;/strong&gt;. You need to have a way to contact the services that compose your application — keep track of their IPs, URIs, and health. This can get out of hand if you have many services that dynamically (horizontally) scale. Traditionally this issue is &lt;a href="https://www.nginx.com/blog/service-discovery-in-a-microservices-architecture/"&gt;solved by utilizing load balancers and service registries&lt;/a&gt; (like &lt;a href="http://zookeeper.apache.org/"&gt;Apache Zookeeper&lt;/a&gt; and &lt;a href="https://www.consul.io/"&gt;Consul&lt;/a&gt;), but &lt;strong&gt;RabbitMQ can partially solve this problem&lt;/strong&gt; for us.&lt;br&gt;&lt;br&gt;Lastly there is &lt;strong&gt;inter-process communication&lt;/strong&gt;, or &lt;a href="https://medium.com/netflix-techblog/fault-tolerance-in-a-high-volume-distributed-system-91ab4faae74a"&gt;sending messages between your services&lt;/a&gt;. RabbitMQ, being a message queue, solves this problem too.&lt;/div&gt;&lt;h2&gt;RabbitMQ&lt;/h2&gt;&lt;div&gt;As the name implies, it's a message queue. Traditionally, in computer science message queues are used for &lt;strong&gt;inter-process communication&lt;/strong&gt;, which can be asynchronous or synchronous. It uses &lt;a href="https://en.wikipedia.org/wiki/Message_queue"&gt;&lt;strong&gt;AMPQ&lt;/strong&gt;&lt;/a&gt; for communicating with clients — it's a wire-level binary protocol that provides mechanisms to ensure message delivery and consumption.&lt;br&gt;&lt;br&gt;I was drawn to RabbitMQ because of two problems I've had while working on a project, &lt;strong&gt;which other message queue services didn't solve&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;Some message queues &lt;strong&gt;don't have job execution guarantees&lt;/strong&gt;, e.g. if a message is removed from a queue by a worker it needs to be processed no matter if the job database restarts or if the worker fails. In RabbitMQ this feature is &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html"&gt;&lt;strong&gt;guaranteed by AMPQ&lt;/strong&gt;&lt;/a&gt;. The protocol specifies that, if desired, a message will be removed from a queue only after the consumer acknowledges it has been processed. If a consumer fails to do so in a given time frame, the message is given to another consumer.&lt;/div&gt;&lt;div&gt;The other problem I had with other queues was memory consumption. &lt;strong&gt;Some message queues keep the whole queue in memory&lt;/strong&gt; which can bring even powerful machines to a crawl if the messages are too big or if there is too many of them. &lt;strong&gt;In RabbitMQ messages are kept in memory until a (configurable) threshold is reached at which point they will be written to disk.&lt;/strong&gt; This is not true for all messages — persistent messages (those in durable queues) will be written to disk and kept in-memory as soon as they are enqueued, and there are also "lazy queues" which try to keep everything on disk and nothing in-memory (if possible).&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2NT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--58f947ff4870b6f2f4f037944e5dc7339d71bc30" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ2tCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4d1836198af51bb4a6965fc79586e5f457b4ce38/sgYa89fDzP_j11PRbWhnMg.png" filename="sgYa89fDzP_j11PRbWhnMg.png" filesize="45523" width="1000" height="357" previewable="true" caption="Queue statistics"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Queue statistics" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY1LCJwdXIiOiJibG9iX2lkIn19--d4ea51e7ab3e5c51a1d2a528d67216f9c38d1a83/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/sgYa89fDzP_j11PRbWhnMg.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Queue statistics
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;RPC / Pub-Sub&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;Most people I've talked to aren't familiar with RabbitMQ's &lt;a href="https://www.rabbitmq.com/direct-reply-to.html"&gt;&lt;strong&gt;"direct reply-to"&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; feature&lt;/strong&gt;. It enables &lt;strong&gt;synchronous inter-process communication&lt;/strong&gt; — you can think of it as an &lt;strong&gt;RPC or a Pub-Sub interface&lt;/strong&gt;. With it, any client can send a message to any queue and get a direct response from any consumer processing messages in that queue. The same rules of memory usage and persistence as described before apply, but &lt;strong&gt;message loss protection is not guaranteed with this mechanism&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;In my opinion, this method of inter-process communication is better than building your services as HTTP servers. With HTTP servers you re-implement logic that is already present in message queues — e.g. success and failure responses, timeouts, and routing. Oftentimes HTTP servers come with unnecessary overhead for a services architecture like some middleware, session storage and encryption mechanisms which can slow the whole application down. &lt;strong&gt;With AMPQ and RabbitMQ you only need to implement your business and serialization logic&lt;/strong&gt; (in what format will the messages be written to the queue).&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;Exchanges&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;In AMPQ, messages aren't published directly to queues, but to exchanges. Exchanges are routers (or post offices) that determine which queues (more than one!) should receive a message.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f6df5a12fc6670ae60b557cf64ee8774caa8ad2d" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZzRCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9841140675bebae013c778461bfd8a23d27d3f0a/Dylm8HewBQraHfs4Wma7EA.png" filename="Dylm8HewBQraHfs4Wma7EA.png" filesize="35154" width="1110" height="430" previewable="true" caption="Topic exchange example"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Topic exchange example" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjcwLCJwdXIiOiJibG9iX2lkIn19--608e49f070acbe273089d1f57dbdfe4e3979d5a5/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Dylm8HewBQraHfs4Wma7EA.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Topic exchange example
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;RabbitMQ supports four types of exchanges — direct, fan-out, topic and headers.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2Nj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--b47ddafcaea5ceb707c60970db33d9347110af5c" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ29CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--33ed9da3f9117ef283bf8bb8fe96b3131b1355c3/GAWkJRBOykY4Ly5TsW4cCA.png" filename="GAWkJRBOykY4Ly5TsW4cCA.png" filesize="374293" width="1400" height="786" previewable="true" caption="Direct exchange example"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Direct exchange example" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY2LCJwdXIiOiJibG9iX2lkIn19--ed60303b73b4135fb36c36272d504045b17c1ea9/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/GAWkJRBOykY4Ly5TsW4cCA.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Direct exchange example
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-direct"&gt;&lt;strong&gt;Direct exchanges&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; deliver messages directly to a single queue&lt;/strong&gt;, e.g. when you don't want to use exchanges and just want to deliver a message to a queue. For example, if each chat room of an application is represented by a queue, a Ruby Chat Exchange would deliver messages &lt;strong&gt;only&lt;/strong&gt; to the Ruby chat queue.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--7971b3f9ec6c809767f05e42325a285ef5442180" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZzhCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3c54d499056b114dcd02134b6a3adce7abd2326e/uEtLTK4ppNItugOfhtEOGA.png" filename="uEtLTK4ppNItugOfhtEOGA.png" filesize="87712" width="700" height="392" previewable="true" caption="Fan-out exchange example"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Fan-out exchange example" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjcxLCJwdXIiOiJibG9iX2lkIn19--072c93a782400fe33fc5c18d8346b4d6e317448f/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/uEtLTK4ppNItugOfhtEOGA.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Fan-out exchange example
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-fanout"&gt;&lt;strong&gt;Fan-out exchanges&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; deliver messages to all queues bound to them&lt;/strong&gt;. You can think of them as groups, a message sent to the group will be delivered to all queues in the group. With the previous chat example, a fan-out exchange can be imagined as a broadcast to select channels. E.g. if we want to send a messsage to all chats of awesome languages we can publish a messsage in the Awesome languages exchange which would deliver those messages to both Ruby and Rust chats.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2Nz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--1692f06f1826c278c1467937e4e8f1b974c11aa4" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ3NCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4fb048e4aaa1e4cabf339f067f564da67d376352/wH85oYIoW7LCj4vzhi7x0Q.png" filename="wH85oYIoW7LCj4vzhi7x0Q.png" filesize="87078" width="700" height="392" previewable="true" caption="Topic exchange example"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Topic exchange example" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY3LCJwdXIiOiJibG9iX2lkIn19--05688dd37c06c443cf6a48e4aac335bc3434dcec/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/wH85oYIoW7LCj4vzhi7x0Q.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Topic exchange example
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;&lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-topic"&gt;&lt;strong&gt;Topic exchanges&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; deliver a message to queues tagged with a topic&lt;/strong&gt; (the above image is an example of such an exchange). E.g. if a message has a routing key (tag) of "Fedex" and it's delivered to the "Shipping" exchange it will be delivered to the "Fedex" queue. Or, with our chat example, a message tagged with text would get delivered to the Chat queue, while a message tagged video would get delivered to the video queue.&lt;br&gt;&lt;br&gt;Lastly, &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-headers"&gt;&lt;strong&gt;header exchanges&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt; are an continuation on the idea of topic exchanges&lt;/strong&gt;, they can take different properties of the message being delivered to determine where it should be delivered.&lt;br&gt;&lt;br&gt;Exchanges come with a multitude of features — &lt;a href="https://www.rabbitmq.com/dlx.html"&gt;dead-lettering&lt;/a&gt; (if a message isn't acknowledged or was rejected it gets sent to another exchange), &lt;a href="https://www.rabbitmq.com/ae.html"&gt;alternate exchanges&lt;/a&gt; (a client can specify an exchange to which a message gets routed if the primary exchange rejects it), &lt;a href="https://www.rabbitmq.com/consumer-priority.html"&gt;priority consumers&lt;/a&gt; (the ability to specify which consumers to prefer, e.g. consumers on more powerful machines), &lt;a href="https://www.rabbitmq.com/priority.html"&gt;priority queues&lt;/a&gt; (messages can be assigned a priority, higher priority messages get processed first), &lt;a href="https://www.rabbitmq.com/ttl.html"&gt;TTLs&lt;/a&gt; (specifies how long a message or queue "lives", though this feature has a caveat — please refer to the manual before using it), and others.&lt;br&gt;&lt;br&gt;They are not only useful for offloading message delivery logic from your services off to RabbitMQ, but also serve as a &lt;strong&gt;way to deprecate services, and reduce downtime&lt;/strong&gt;. If a service is to be deprecated, its messages can be routed to another service that knows how to process them, or that returns errors interpretable by other services as responses. If a new version of a service is about to be deployed an exchange can be configured to deliver messages simultaneously to the old service's and the new service's queues. Or, when the new service is up, all incoming messages can be redirected to it.&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;Service discovery&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;Since RabbitMQ supports multiple producer and multiple consumer queues, &lt;strong&gt;exchanges can be utilized as a form of service discovery&lt;/strong&gt;. Each service knows its name and the names of the services it depends on, therefore it only has to publish messages on their exchanges (or fail if an exchange doesn't exist), and listen for messages on its queue.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--1f30542d0fd662447f744fa06f1d947b285b8d33" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaEFCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9b5a7b13ac78138b76b1f83f78e7dc12a323e297/YaTuK3Y5j_6iozZN29ogjg.png" filename="YaTuK3Y5j_6iozZN29ogjg.png" filesize="77634" width="1523" height="532" previewable="true" caption="List of available queues"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="List of available queues" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjcyLCJwdXIiOiJibG9iX2lkIn19--cecd0eb7d30fc82c87685b308b7df3cdfa4e5308/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/YaTuK3Y5j_6iozZN29ogjg.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      List of available queues
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;For small services architectures this kind of service discovery is sufficient. All services will communicate through RabbitMQ, and therefore we are able to keep track of them. But sometimes the need arises to communicate with other applications that don't use AMPQ, or keep track of applications in a cluster (e.g. for databases, or RabbitMQ instances) — this kind of service discovery is &lt;strong&gt;not suitable for those use-cases&lt;/strong&gt; and should be handled with the like of Consul or Zookeeper.&lt;/div&gt;&lt;h2&gt;&lt;strong&gt;Plugins&lt;/strong&gt;&lt;/h2&gt;&lt;div&gt;Personally, I find this to be the "&lt;strong&gt;killer feature&lt;/strong&gt;" of RabbitMQ. If RabbitMQ is missing any kind of functionality you desire it can be added. Out-of-the-box it comes with quite a few useful plugins.&lt;br&gt;&lt;br&gt;The one plugin I use most often, and the first plugin I introduce people to is the &lt;a href="https://www.rabbitmq.com/plugins.html"&gt;&lt;strong&gt;Management plugin&lt;/strong&gt;&lt;/a&gt;. It gives RabbitMQ a full user interface through which you can configure and monitor individual exchanges and queues, monitor system performance, memory and disk usage.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI2OD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--7f9dd48461d9231d348b5e91d240003d229e190a" content-type="image/png" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBZ3dCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3bdf28275149214aceb97d6cb6013dcb1b0cc18d/Zrhh-vWJRIiaTvnPzyJZnQ.png" filename="Zrhh-vWJRIiaTvnPzyJZnQ.png" filesize="294525" width="2000" height="1232" previewable="true" caption="Overview panel in the Management plugin"&gt;
&lt;figure class="attachment attachment--preview attachment--png"&gt;

      &lt;img alt="Overview panel in the Management plugin" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjY4LCJwdXIiOiJibG9iX2lkIn19--dc22bc859dff1f6200f69ac8f30a2bac84df27e0/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJwbmciLCJyZXNpemVfdG9fbGltaXQiOlsxMDI0LDc2OF0sInNhdmVyIjp7InN0cmlwIjp0cnVlfX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--19d1a30d08351b790965f3a1ca0a67c260d962d0/Zrhh-vWJRIiaTvnPzyJZnQ.png"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Overview panel in the Management plugin
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Through plugins &lt;strong&gt;RabbitMQ can support competing protocols to AMPQ&lt;/strong&gt; — like MQTT, STOMP and WebSockets.&lt;br&gt;&lt;br&gt;And while clustering is supported, the &lt;strong&gt;Federation plugin&lt;/strong&gt; brings it to a new level. It enables message passing between brokers without clustering (useful since RabbitMQ requires IP addresses of the other instances, instead of URIs, when clustered). E.g. multiple clusters of RabbitMQ can exchange messages between each other. An useful analogy would be Mastodon's and Diaspora's federations which allow users on different instances to communicate as if they were on the same instance. It also comes with a UI that's accessible through the Management plugin.&lt;br&gt;&lt;br&gt;Lastly, there is a feature called "&lt;strong&gt;firehose&lt;/strong&gt;" that logs all internal messages of RabbitMQ. It's extremely useful for debugging plugin behavior, exchange configuration and even just for logging.&lt;/div&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;div&gt;RabbitMQ has helped me manage and scale my services architectures by utilizing plugins, exchanges, queues and the RPC interface. It's become an essential tool for building services.&lt;br&gt;&lt;br&gt;&lt;strong&gt;My biggest issue now is my dependency on it&lt;/strong&gt; — it's become the backbone of my services architectures. This introduced a large single point of failure, but it's manageable with the Federation plugin and clustering.&lt;br&gt;&lt;br&gt;If you are struggling with the limitations of your queue service, clustering or inter-process communication — &lt;strong&gt;try RabbitMQ&lt;/strong&gt;. It excels at all those tasks and brings many utilities that can prove helpful for managing your application.&lt;br&gt;&lt;br&gt;If you are struggling to introduce RabbitMQ to your project. &lt;strong&gt;Try it as a simple queue first&lt;/strong&gt;, and then slowly move more and more services to it&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">When I first started using RabbitMQ I didn't understand its usefulness beyond a job queue, but it's helped me to grow and manage services architectures without headaches.
Services ArchitectureIn web development, a services architecture describes a single application that consists of multiple,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/10</id>
    <published>2018-02-27T12:00:00Z</published>
    <updated>2024-03-22T14:22:19Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/rabbitmq-is-more-than-a-sidekiq-replacement-9sL5VI530eMo"/>
    <title>RabbitMQ is more than a Sidekiq replacement!</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;We are used to Sidekiq being the defacto norm for a queue service in&lt;br&gt;&amp;nbsp;the Ruby ecosystem. If you replace Sidekiq and Redis with RabbitMQ&lt;br&gt;&amp;nbsp;you may find yourself with a few less problems, and more tools to scale&lt;br&gt;&amp;nbsp;your app.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This talk will go into problems with using Sidekiq and Redis as a queue service,&lt;br&gt;&amp;nbsp;migrating to RabbitMQ, and using RabbitMQ’s features to scale your Ruby apps a&lt;br&gt;&amp;nbsp;bit easier.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2018-02-28/rabbitmq_is_more_than_a_sidekiq_replacement.pdf"&gt;Slides&lt;/a&gt; &lt;a href="https://stanko.io/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb"&gt;Article&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">We are used to Sidekiq being the defacto norm for a queue service in
 the Ruby ecosystem. If you replace Sidekiq and Redis with RabbitMQ
 you may find yourself with a few less problems, and more tools to scale
 your app.
This talk will go into problems with using Sidekiq and Redis as a queue...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/9</id>
    <published>2017-11-28T12:00:00Z</published>
    <updated>2024-03-22T14:09:27Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/helix-Bb3vvarckPXR"/>
    <title>Helix</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Helix is a Rust library for writing Ruby extension. With it you can write hassle free extensions with confidence that they won't fail.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;This talk will introduce you to Ruby extensions, Rust and Helix.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2017-11-28/helix.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Helix is a Rust library for writing Ruby extension. With it you can write hassle free extensions with confidence that they won't fail.
This talk will introduce you to Ruby extensions, Rust and Helix.

Slides</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/2</id>
    <published>2017-10-06T16:55:00Z</published>
    <updated>2023-10-29T13:07:32Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/developing-a-safer-web-with-rust-DfhyvPw3yruT"/>
    <title>Developing a Safer Web with Rust</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Developing web services with Rust. Using Rust's built in safety and open-source frameworks to make fast and fault-tolerant web applications.&lt;br&gt;We all want to write high-performing, scalable, safe code with as few bugs as possible. But with threading, data races, SQL injection, out-of-bounds access and segmentation faults this can prove to be a hassle.&lt;br&gt;Rust solves most of those issues out-of-the-box, but with projects such as Rocket and Diesel those guarantees can be extended to the level of a framework.&lt;br&gt;Providing an easy and fast way to write high-performing fault-tolerant web applications.&lt;br&gt;Perhaps your next web app should be written in Rust?&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Developing web services with Rust. Using Rust's built in safety and open-source frameworks to make fast and fault-tolerant web applications.
We all want to write high-performing, scalable, safe code with as few bugs as possible. But with threading, data races, SQL injection, out-of-bounds access...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/15</id>
    <published>2017-05-17T12:00:00Z</published>
    <updated>2024-03-22T14:26:22Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/developing-a-faster-and-safer-web-with-rust-aX7rlu4J81Cv"/>
    <title>Developing a faster and safer web with Rust</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Rust provides a safe way to do concurrent programming without the speed penalty. It's finally web-ready. This talk will guide you through creating a web application in Rust. Through that process, available libraries will be outlined and compared to their counterparts in other ecosystems.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Rust provides a safe way to do concurrent programming without the speed penalty. It's finally web-ready. This talk will guide you through creating a web application in Rust. Through that process, available libraries will be outlined and compared to their counterparts in other ecosystems.</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/8</id>
    <published>2017-03-28T12:00:00Z</published>
    <updated>2024-03-22T14:07:46Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/graphql-oKdA0gYkLdZq"/>
    <title>GraphQL</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;GraphQL is a better way to write APIs. It is auto-documenting, simple to understand, flexible yet powerful. Lets explore how we can build APIs using its Ruby driver and solve common problems that we face when using techniques such as REST, JSON::API or HAL.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2017-03-28/graphql.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">GraphQL is a better way to write APIs. It is auto-documenting, simple to understand, flexible yet powerful. Lets explore how we can build APIs using its Ruby driver and solve common problems that we face when using techniques such as REST, JSON::API or HAL.

Slides</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/7</id>
    <published>2016-10-25T12:00:00Z</published>
    <updated>2024-03-22T14:06:11Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/steganography-2qujhKxhy9ow"/>
    <title>Steganography</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Sick and tired of those pesky watermarks ruining photos? Or perhaps you have something to hide, but don’t want people to suspect something? Using Ruby and steganography it’s possible to hide messages in plain sight. Though it seems useless it’s applications are limitless.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2016-10-25/steganography.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Sick and tired of those pesky watermarks ruining photos? Or perhaps you have something to hide, but don’t want people to suspect something? Using Ruby and steganography it’s possible to hide messages in plain sight. Though it seems useless it’s applications are limitless.

Slides</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/3</id>
    <published>2016-09-28T12:00:00Z</published>
    <updated>2023-10-29T13:12:52Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/stubs-mocks-spies-MdyHowlg0PqN"/>
    <title>Stubs, Mocks &amp; Spies</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;1000 tests running for hours on continuous integration services should be a thing of the past.&amp;nbsp;&lt;br&gt;Don't lose time waiting for your tests.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">1000 tests running for hours on continuous integration services should be a thing of the past. 
Don't lose time waiting for your tests.</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/2</id>
    <published>2016-05-23T00:00:00Z</published>
    <updated>2026-03-09T08:21:18Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/tips-to-improve-your-tests-414733ecbc4-414733ecbc4"/>
    <title>Tips to improve your tests</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI3Mz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--5b555dc466537d19183395798721137bec224c50" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaEVCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--afe0513555743a5189ac094470f5c7812d34ceb3/TSnYL5U5EnQJg5xEeysMGg.jpeg" filename="TSnYL5U5EnQJg5xEeysMGg.jpeg" filesize="1192456" width="4000" height="2076" previewable="true" presentation="gallery" caption="A shoe stuck in the railing of a bridge in Stockholm"&gt;
&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="A shoe stuck in the railing of a bridge in Stockholm" loading="lazy" style="aspect-ratio:4000 / 2076;" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjczLCJwdXIiOiJibG9iX2lkIn19--4d89dc0443dffb9b5017412ba8184e5c73a08e28/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/TSnYL5U5EnQJg5xEeysMGg.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      A shoe stuck in the railing of a bridge in Stockholm
  &lt;/figcaption&gt;
&lt;/figure&gt;Peoples' test suites often get out of hand. Having thousands of tests that run for hours on some CI should be a thing of the past. There doesn't exist code that's too hard to test or code that can't fail. You shouldn't waste your time waiting for your test suite, or chasing some bug you are unable to pinpoint. Writing a test suite that covers 100% of your code base isn't hard. But to do it right is extremely hard.&lt;br&gt;&lt;br&gt;As I said earlier, all code is 'testable'. There only exists the age-old question of "should I write code that is easy to test?". By now, this discussion has turned into a 'religious war' of sorts. There is no definitive answer to this question.&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Writing 'testable' code&lt;/h2&gt;&lt;p&gt;&lt;b&gt;&lt;strong&gt;You shouldn't write code with your test suite in mind&lt;/strong&gt;&lt;/b&gt;. Write code that makes sense. Write code that is easy to read, and easy to understand. Most of all, &lt;b&gt;&lt;strong&gt;write code that is maintainable&lt;/strong&gt;&lt;/b&gt;. If you are having problems coming up with a test suite for the code you wrote then go back and evaluate if your code is as good as it can be. More often than not you will find a way to refactor the code so that it makes more sense, to make it more readable, or to make it maintainable.&lt;br&gt;&lt;br&gt;You can think of this as a positive feedback loop. Your code becomes easier to test the 'better' code you write. Better in the sense that it's maintainable, readable and sensible. Implementing one pattern (or good practice) will lead you to use other related patterns. And in the end, you will have a solid code base with an excellent test suite.&lt;br&gt;&lt;br&gt;There are exceptions to this rule. Sometimes, 'good' code is hard to test. That's why you should always write code without your test suite in mind.&lt;br&gt;&lt;br&gt;Knowing that your application, as a whole, returns expected results for certain scenarios isn't that reassuring. You can never cover all possible combinations of states in an application.&lt;br&gt;&lt;br&gt;Therefore, it's better to test each module of your application by itself. Rather said — unit test your application. That way you can cover more, if not all possible states. And thus, be confident that your application will do what you expect from it. It also makes refactoring easier as you can detect if you changed an output without intention.&lt;/p&gt;&lt;h2&gt;Write more unit tests&lt;/h2&gt;&lt;p&gt;Unit tests are generally faster than integration test. Thus, they are cheaper to run. You can have thousands of unit tests that run in a couple of seconds and be assured that your code does what you expect from it. While running the same amount of integration tests might take hours. And they wouldn't tell you if your application performs as expected in all situations.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;You should write integrations tests!&lt;/strong&gt;&lt;/b&gt; They assure that all modules of your application work well together. Hence the name — integration tests. Each feature (code execution path) should have at least one integration test. But, there is no rule to determine how many integration tests you should have. My rule of thumb is that, after you run only your integration tests, you should have code coverage greater than zero on each module.&lt;br&gt;&lt;br&gt;It's only important that your integration tests reassure that the modules of which your application is made of can interact together without a fault.&lt;action-text-attachment content-type="image" url="https://media.giphy.com/media/3o7rbGT9rrEJa4Y5LG/giphy.gif" filename="" width="480" height="270" presentation="gallery" caption=""&gt;&lt;/action-text-attachment&gt;&lt;/p&gt;&lt;figure class="attachment attachment--preview"&gt;
  &lt;img width="480" height="270" src="https://media.giphy.com/media/3o7rbGT9rrEJa4Y5LG/giphy.gif"&gt;
&lt;/figure&gt;&lt;p&gt;&lt;/p&gt;&lt;h2&gt;Stubs and mocks&lt;/h2&gt;&lt;p&gt;I often see people letting their 'unit' test call code from a module that isn't their module under test. That effectively turns a unit into an integration test.&lt;br&gt;&lt;br&gt;&lt;b&gt;&lt;strong&gt;The basic idea of stubs is to decouple modules from one another&lt;/strong&gt;&lt;/b&gt;. Stubs, as the name implies, stub out the behaviour of method calls. You can think of stubs as predefined responses to method calls. Stubbing a call to a method never executes the method's code, it just returns the desired value then and there. This is an incredibly powerful tool! &lt;b&gt;&lt;strong&gt;Not only can you decouple modules this way, but you can control your program's flow&lt;/strong&gt;&lt;/b&gt;. By stubbing certain methods you can simulate success and failure conditions without the need to craft a complex data set that triggers the same behavior.&lt;br&gt;&lt;br&gt;Stubs can't assure that a method got called, which is important if that call does something 'mission critical'. Mocks were invented for this kind of situations. Mocks are identical to stubs, but they expect that the mocked method will get called at least once. If the method doesn't get called the test explicitly fails.&lt;br&gt;&lt;br&gt;Using these two tools you can test all possible scenarios without the need for complex data sets and repercussion checking. This is the first step to making your tests run blazing fast!&lt;/p&gt;&lt;h2&gt;Asserting vs. mocking&lt;/h2&gt;&lt;p&gt;The question arises of when to assert a value and when to mock a call. &lt;i&gt;&lt;em&gt;We can differentiate two kinds of method calls — queries and commands&lt;/em&gt;&lt;/i&gt;. Queries are calls that return a value while commands are calls that change the state of the system. For instance, reading a file is a query as it returns the contents of the file. While writing a file is a command as it creates a file with a given content, thus changing the state of the file system.&lt;br&gt;&lt;br&gt;We also differentiate incoming and outgoing calls. &lt;b&gt;&lt;strong&gt;Outgoing calls are calls that your module under test makes to other modules. While incoming calls are calls made to the module under test&lt;/strong&gt;&lt;/b&gt;.&lt;br&gt;&lt;br&gt;The returned value of outgoing queries should not be asserted. These values will be asserted in the respective module's unit test. Outgoing commands should be mocked. We rely on other module's commands to work. It's only important that we know that they got called. By knowing this, we can stub out the behavior of other modules to simulate the command's repercussions without causing an artificial success state.&lt;br&gt;&lt;br&gt;The returned value of incoming queries &lt;i&gt;&lt;em&gt;should be&lt;/em&gt;&lt;/i&gt; asserted. We need to make sure that given an input our method returns an expected result. Incoming commands' repercussions should be asserted for the same reason incoming queries should be asserted.&lt;/p&gt;&lt;figure class="lexxy-content__table-wrapper"&gt;&lt;table&gt;&lt;tbody&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;/th&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Query&lt;/p&gt;&lt;/th&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Command&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Incoming&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;assert return value&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;assert side effect&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;th class="lexxy-content__table-cell--header"&gt;&lt;p&gt;Outgoing&lt;/p&gt;&lt;/th&gt;&lt;td&gt;&lt;p&gt;do not assert&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p&gt;assert message sent&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;&lt;p&gt;&lt;br&gt;There also exist methods that are both queries and commands. A combination of the above rules should be applied when testing them.&lt;/p&gt;&lt;h2&gt;Write tests first?&lt;/h2&gt;&lt;p&gt;If you know what your module should do, then by all means you should write a test suite first. A test suite should always come first. That way you can ensure that the code you wrote complies to expectations.&lt;br&gt;&lt;br&gt;There are exceptions to this rule. If you are unsure about the final architecture of the code or think that the code will drastically change during development. Then it's better to write a test suite after the fact.&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;No matter how you test your software you can never assure that no bugs are present. The methods outlined here are tools to help you write a better test suite. They should be food for thought, not hard rules to follow.&lt;/p&gt;&lt;blockquote&gt;Testing shows the presence, not the absence of bugs&lt;br&gt;&lt;br&gt;— Edsger W. Dijkstra&lt;/blockquote&gt;&lt;p&gt;Having unit, integration tests, mocks, and spies gives you the assurance that your code does what you expect it to do. Being able to run your test suite fast enables you to iterate quickly. These two together, speed and confidence, enable you to write software at an incredible pace thus giving you time to improve your methods and skills. Resulting in incredible software.&lt;/p&gt;
&lt;/div&gt;
</content>
    <summary type="html">Peoples' test suites often get out of hand. Having thousands of tests that run for hours on some CI should be a thing of the past. There doesn't exist code that's too hard to test or code that can't fail. You shouldn't waste your time waiting for your test suite, or chasing some bug you are...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/6</id>
    <published>2016-04-28T12:00:00Z</published>
    <updated>2024-03-22T14:04:28Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/stubs-mocks-spies-7GHQbNHP6vdJ"/>
    <title>Stubs, Mocks &amp; Spies</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;What are stubs, mocks, spies and fakes?&lt;br&gt;How can they improve my tests?&lt;br&gt;Those are two questions that I want to answer with this talk.&lt;br&gt;Stubs, mocks and spies are essential tools of every testing library.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2016-04-28/stubs_mocks_spies.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">What are stubs, mocks, spies and fakes?
How can they improve my tests?
Those are two questions that I want to answer with this talk.
Stubs, mocks and spies are essential tools of every testing library.

Slides</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Article/1</id>
    <published>2016-04-25T00:00:00Z</published>
    <updated>2026-02-15T09:37:07Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5"/>
    <title>Hacking privacy into Facebook’s Messenger in 24 hours</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI4Mj9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a8ee9723390891beb506754c650848217fdd3567" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaG9CIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--fb9b085a84199807c3d8aeead5201b0f3ff98183/952-NqgY3pKez3-H7I-7EA.jpeg" filename="952-NqgY3pKez3-H7I-7EA.jpeg" filesize="1473096" width="4000" height="2250" previewable="true" caption="The Copenhagen town hall building"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="The Copenhagen town hall building" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjgyLCJwdXIiOiJibG9iX2lkIn19--0f0881535317aa43cea4356524644ee9ba9be7fd/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/952-NqgY3pKez3-H7I-7EA.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      The Copenhagen town hall building
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Hackathons are great. When a friend of mine asked me if I wanted to go with him to &lt;a href="http://copenhacks.com/"&gt;Copenhacks&lt;/a&gt; I had no idea that we would spend 24 hours reverse engineering &lt;a href="https://www.messenger.com/"&gt;Facebook's Messenger&lt;/a&gt;, let alone win first place.&lt;/div&gt;&lt;h2&gt;The journey to Copenhagen&lt;/h2&gt;&lt;div&gt;We usually go to a lot of hackathons and coding competitions, but we never went to a hackathon outside Croatia. These are usually either too expensive or at an inconvenient date for us. When we heard of Copenhacks we decided to go, as all of us were free at the time and wanted to see what hackathons looked like in other countries. Hackathons are also a good opportunity to meet recruiters or get internships at one of the sponsors. Finally, let's not forget all the swag that gets handed out!&lt;br&gt;&lt;br&gt;Copenhacks was a new thing, so we didn't know what to expect. The idea for such a competition came around mid 2015 by a student at the local technical university in the beautiful city of Copenhagen, Denmark. His idea was to create a competition where people could come and build amazing stuff, learn new things and meet fellow hackers from around the world. That's quite different from the other hackathons I went to. Usually the main sponsoring company gives out a specification for an app and then you have a set amount of time to implement it. This was completely different. We could do whatever we wanted. This was also a problem because many questions arose, like &lt;em&gt;'what could we do?'&lt;/em&gt;, &lt;em&gt;'what would impress the judges?'&lt;/em&gt;, &lt;em&gt;'what did we have time to implement in 24 hours?'&lt;/em&gt;. We had several ideas but none of them felt right. We knew we wanted to make something fun and open-source, so all commercial projects fell out of the picture.&lt;br&gt;&lt;br&gt;Our flight to Copenhagen was getting closer and closer and we couldn't settle for an idea. The last night before the flight all of us got into a call and started brainstorming. As all of us are cryptography enthusiasts (and perhaps a bit paranoid) and all of us use Messenger all the time. We decided to combine the two together just because it would be convenient and secure. Thus the idea for MessengerPG was born!&lt;br&gt;&lt;br&gt;MessengerPG should be a browser extension that unobtrusively adds client-side message signing, encryption and decryption with PGP. The cryptography should be done using the local GPG installation. Messenger would only be used as means to transfer the messages from client to client. The extension would collect the public PGP keys for all chat participants from their Facebook profiles. And, yes, &lt;a href="https://www.facebook.com/notes/protect-the-graph/securing-email-communications-from-facebook/1611941762379302/"&gt;you can add your public PGP key to your Facebook profile&lt;/a&gt;. The sent messages can only be decrypted with the private keys of each individual participant of the conversation. Everybody else sees only seemingly random strings of letters — this includes Facebook.&lt;br&gt;&lt;br&gt;More importantly, our extension would be a platform on top of which anybody could build their own extension to Messenger.&lt;/div&gt;&lt;h2&gt;The hackathon&lt;/h2&gt;&lt;div&gt;My friend once said &lt;em&gt;'Hackathons are an interesting social experiment'&lt;/em&gt;. That's perhaps the shortest way to say it. Hackathons give quite a small amount of time to create something complicated, which causes a lot of stress and leads to sleep deprivation. That's when people start getting into arguments and fights. Then the whole experience turns into a delicate dance of not stepping on each other's toes.&lt;br&gt;&lt;br&gt;Every time we started getting angry at each other we took a walk, grabbed a soda, played some table-tennis or went to somebody's desk and tried to help them with their problem. This helped, at least me, a lot. Not only did I get to meet people but I saw all the brilliant ideas that others came up with.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI4MD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f86123a0fd03f64387951627ec36fc0bfa786821" content-type="image/gif" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaGdCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--27f7c596a019751a86e0e445eec66111e2556619/wCSUEbT71HObqp90DNlDHA.gif" filename="wCSUEbT71HObqp90DNlDHA.gif" filesize="3346613" width="366" height="206" previewable="true" caption="Us playing table-tennis at 3 am"&gt;
&lt;figure class="attachment attachment--preview attachment--gif"&gt;
      &lt;img alt="Us playing table-tennis at 3 am" loading="lazy" src="/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjgwLCJwdXIiOiJibG9iX2lkIn19--21e0e63119cbc43bc930ba2420de032bd23681a8/wCSUEbT71HObqp90DNlDHA.gif"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Us playing table-tennis at 3 am
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;Another good piece of advice is to celebrate each victory no matter how small it is as it helps to keep your spirits up. I can recall the moment when we finally managed to intercept incoming and outgoing messages. We got up and started cheering. Each time we managed to solve a problem, we gave each other a high-five.&lt;br&gt;&lt;br&gt;But the key to getting the best possible product in the smallest amount of time is planning and organization. An hour before the start of the competition we divided our responsibilities.&lt;br&gt;&lt;br&gt;&lt;a href="https://markobozac.com/"&gt;Marko Božac&lt;/a&gt; would be making the presentation website. &lt;a href="https://luka.strizic.info/"&gt;Luka Strižić&lt;/a&gt; would be making an interface between the local GPG installation and our extension. &lt;a href="https://psegina.com/"&gt;Petar Šegina&lt;/a&gt; and I would reverse engineer Messenger and find a way to inject our code. That plan worked out quite well for us, as all of us managed to get three hours of sleep, several meals and we even finished with half an hour to spare.&lt;/div&gt;&lt;h2&gt;Reverse engineering Messenger&lt;/h2&gt;&lt;div&gt;Finding an entry vector in JS applications usually isn't all that hard because you can read their source and manipulate all objects as they live in your browser. This was a bit harder. Messenger's source is split into several minified files which makes them hard to understand and it's source is bundled with &lt;a href="https://facebook.github.io/react/"&gt;React&lt;/a&gt; which adds a lot of code to sift through.&lt;br&gt;&lt;br&gt;It was obvious that Messenger needs to send and receive data from a server so we opened up Chrome's developer tools and started to monitor incoming and outgoing network traffic. There we noticed that each time you sent a message a request to &lt;strong&gt;send_messages.php&lt;/strong&gt; was being made. This was our entry point. We created an XHR breakpoint with the URL and sent a message. This triggered the breakpoint and showed us the call-stack which we then traversed until we found the &lt;strong&gt;getValue&lt;/strong&gt; function that read text entered into the input field. This was the function we needed to monkey patch to call another function that would process the message before it was sent.&lt;br&gt;&lt;br&gt;We needed to find a way to get hold of a reference to the object which made the call to &lt;strong&gt;getValue&lt;/strong&gt; and monkey patch it. The window object doesn't have a reference to Messenger's main process but it has a reference to the module loader &lt;strong&gt;__d&lt;/strong&gt; which we then patched to give us a reference to the object we needed.&lt;br&gt;&lt;br&gt;Receiving messages was a different ball game. We couldn't simply listen for incoming messages and decrypt them before they were passed on to the rendering logic because then only newly received messages would get decrypted. After a page refresh all messages would appear encrypted again. We decided to monkey patch React's render method, as we were sure that it would get called for each and every message that needed to be displayed on the screen. We used the same code injection method as before. The code we added waits for a render call to create a object with a message's CSS class. Then it tries to find a PGP encrypted and signed message and decrypts it.&lt;br&gt;&lt;br&gt;This all sounds easy enough, but it was a really complicated and frustrating task. We didn't know what Messenger's architecture looked like so we had to figure out how all the things work together. We spent a lot of time trying different approaches and discussing possible solutions. Perhaps the best metric to show how complicated the reverse engineering process was is the fact that two engineers wrote a total of 10 (usable) lines of code per hour.&lt;br&gt;&lt;br&gt;Many compromises have been made during the development process, some due to time and some due to technical constraints. In order to access the local GPG keychain we built a simple Node.js application that allows us to sign, encrypt and decrypt messages through a simple HTTP API. This led to an unexpected problem with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/CSP"&gt;CSP&lt;/a&gt; because Facebook prohibits any outgoing communication to non-Facebook approved domains. We tried to use &lt;a href="https://wiki.greasespot.net/GM_xmlhttpRequest"&gt;unsafe XMLHTTPRequests&lt;/a&gt; but ultimately failed, so a hack was in order. We registered &lt;strong&gt;pgp.messenger.com&lt;/strong&gt; in the local &lt;strong&gt;hosts&lt;/strong&gt; file, and pointed it to 127.0.0.1. This meant that our local API was now located on a CSP trusted domain. However, we still needed to access the API over SSL (as the CSP required https://), so we created a proxy server to our GPG API server, running with locally generated SSL certificates. Getting the browser to trust the generated SSL certificate, being the last hurdle, was not much of a problem.&lt;action-text-attachment sgid="BAh7CEkiCGdpZAY6BkVUSSIyZ2lkOi8vYmxvZy9BY3RpdmVTdG9yYWdlOjpCbG9iLzI4MT9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--2dd07dd2e0bfdcda9b4c9e1ba4b56f729c929eab" content-type="image/jpeg" url="https://stanko.io/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaGtCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3b4035b8c207a3e344ec43e37894f2a182fec397/w_5AG9SzEJ3--E-27k2yyA.jpeg" filename="w_5AG9SzEJ3--E-27k2yyA.jpeg" filesize="98803" width="700" height="466" previewable="true" caption="Warning, hack in progress!"&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg"&gt;

      &lt;img alt="Warning, hack in progress!" loading="lazy" src="https://stanko.io/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjgxLCJwdXIiOiJibG9iX2lkIn19--dc35cc60c6518ce9494e1132317be66dffe016fb/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGVnIiwicmVzaXplX3RvX2xpbWl0IjpbMTAyNCw3NjhdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZX19LCJwdXIiOiJ2YXJpYXRpb24ifX0=--99da3a578943905190afc960ed27200bc3f821e3/w_5AG9SzEJ3--E-27k2yyA.jpeg"&gt;

  &lt;figcaption class="attachment__caption"&gt;
      Warning, hack in progress!
  &lt;/figcaption&gt;
&lt;/figure&gt;&lt;/action-text-attachment&gt;I think we all felt a great relief when we implemented the last feature with half an hour to spare. But the relief was short-lived. Soon the presentations started and to the stage came team after team of really talented people. One particular team found a way to misuse Android's accessibility features to create a system wide chat-bot. A different team created a chat-bot that understood natural language and helped sort out arguments between people. Others made games that looked really polished. And then came we, with our short presentation which showed a few messages being exchanged. Plain text on the left, encrypted and signed on the right. Packaged in with a short talk about how we managed to hook in to Messenger and that this can be used not only for encryption but as a platform for all kinds of extensions.&lt;br&gt;&lt;br&gt;&lt;a href="https://youtu.be/S-LVSCIzip0"&gt;The extension in action — With the extension (left) and without (right)&lt;/a&gt;&lt;br&gt;&lt;br&gt;After the presentations were over none of us believed that we would win anything. But that didn't matter! We created something really cool, traveled together, had an excellent weekend, drank a couple of beers and had fun in general. For us, no matter if we won any prize or not, this was a success. This was what a hackathon should be about!&lt;/div&gt;&lt;h2&gt;The anticipation&lt;/h2&gt;&lt;div&gt;After a while the room filled with people again, but this time for the prizes to be handed out. One prize was handed out after the other, but our name was never mentioned. Then came the prizes for the top three teams. By this point most of us stopped listening and only waited for the ceremony to be over as we desperately needed sleep. Before the first prize was given out there was a short inspiring speech by the organisers. It explained that they picked first place based on the hacker spirit, problem complexity and general usefulness of the project. Then they called our name. We were in shock. No one expected this to happen. Everybody's faces were white. When it finally hit us that we won, we rushed to the stage to pick up our prize. And so the whole experience was over.&lt;br&gt;&lt;br&gt;This hackathon was something I will remember, not just because we did something awesome, but because it was really fun.&lt;br&gt;&lt;br&gt;The organisers did an outstanding job, the food was great, the venue was even better. Thanks to all the sponsors for all the awesome lectures (and all the cool swag). Most of all, thanks to my team which made Copenhagen an experience to remember.&lt;br&gt;&lt;br&gt;If you are thinking about going to such an event then do it. Don't hesitate. It doesn't matter if you win a prize or not. At the end of the day you will either create something awesome or learn a valuable lesson. Similarly, if you are thinking of organising a hackathon, don't hesitate. You will change somebody's life forever and meet really cool people in the process.&lt;/div&gt;&lt;h2&gt;Conclusion — Privacy is hard&lt;/h2&gt;&lt;div&gt;If you want to help us make MessengerPG a thing or to build your own extension on top of Messenger, take a look at &lt;a href="https://github.com/monorkin/copenhacks-2016"&gt;our Github page&lt;/a&gt;. Fork us. Look at the code. Change something. And feel free to open up a pull request. We will gladly take all the help we can get.&lt;br&gt;&lt;br&gt;Finally, a word of caution. There is currently no way to guarantee that the code you audit will be the code you run next time you open Messenger. This allows malicious servers to serve targeted users with different versions of the code. So, while 99% of users may get clean, non-malicious code, the interesting 1% can get served with code endangering their privacy. This is also a problem with closed-source messaging services which offer end-to-end encryption which cannot be properly audited. Even if the messages being exchanged are encrypted end-to-end, what's stopping the proprietary code from collecting the messages you exchange and exposing them over a different channel?&lt;br&gt;&lt;br&gt;Privacy is hard. It is not something we should take for granted, but something we should actively try to achieve and protect.&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Hackathons are great. When a friend of mine asked me if I wanted to go with him to Copenhacks I had no idea that we would spend 24 hours reverse engineering Facebook's Messenger, let alone win first place.
The journey to CopenhagenWe usually go to a lot of hackathons and coding competitions, but...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/5</id>
    <published>2016-02-25T12:00:00Z</published>
    <updated>2024-03-22T14:02:38Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/docker-for-rubyists-dEUwfw2w6EYv"/>
    <title>Docker for Rubyists</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;Use Docker to solve common development and deployment problems without making too many changes to your workflow and development environment.&lt;br&gt;&lt;br&gt;&lt;/div&gt;&lt;div&gt;The goal of this presentation is to introduce you to Docker, present it as a development tool and to explain the strengths and weaknesses of this development approach.&lt;br&gt;&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2016-02-25/docker_for_ruby_devs.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">Use Docker to solve common development and deployment problems without making too many changes to your workflow and development environment.
The goal of this presentation is to introduce you to Docker, present it as a development tool and to explain the strengths and weaknesses of this...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
  <entry>
    <id>tag:stanko.io,2005:Talk/4</id>
    <published>2015-03-31T12:00:00Z</published>
    <updated>2024-03-22T14:02:45Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/talks/ruby-extensions-Uma3TYttk1tT"/>
    <title>Ruby Extensions</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;div&gt;How to achieve better performance by offloading computationally heavy tasks to a "faster" language, but still keep a Ruby-like interface&lt;br&gt;&lt;a href="https://github.com/rubyzg/slides/blob/master/2015-03-31/ruby_extensions.pdf"&gt;Slides&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
</content>
    <summary type="html">How to achieve better performance by offloading computationally heavy tasks to a "faster" language, but still keep a Ruby-like interface
Slides</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
</feed>
