<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/feed.xsl" type="text/xsl"?>
<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom">
  <id>tag:stanko.io,2005:/articles/feed</id>
  <link rel="alternate" type="text/html" href="https://stanko.io/"/>
  <link rel="self" type="application/atom+xml" href="https://stanko.io/articles/feed"/>
  <link rel="first" type="application/atom+xml" href="https://stanko.io/articles/feed"/>
  <link rel="last" type="application/atom+xml" href="https://stanko.io/articles/feed"/>
  <title>Stanko Krtalic Rusendic: Articles</title>
  <updated>2026-08-05T07:57:18Z</updated>
  <entry>
    <id>tag:stanko.io,2005:Article/1073200335</id>
    <published>2026-08-05T07:26:18Z</published>
    <updated>2026-08-05T07:57:18Z</updated>
    <link rel="alternate" type="text/html" href="https://stanko.io/a-year-at-37signals-8s17uw9GPHgD"/>
    <title>A year at 37signals</title>
    <content type="html">&lt;div class="lexxy-content"&gt;
  &lt;p&gt;A year ago I had my first day at 37signals (the people behind Basecamp, HEY and
Fizzy).&lt;/p&gt;
&lt;p&gt;This was a dream come true for me.&lt;/p&gt;
&lt;p&gt;I wanted to work there ever since I read Rework over a decade ago. The books,
podcasts and talks that followed showed me that we also share a similar
philosophy towards work and the craft of coding. That just cemented my resolve.&lt;/p&gt;
&lt;p&gt;Not to mention that it&amp;#39;s the birthplace of Rails! The single piece of software
that made me decide to turn my programming hobby into my career.&lt;/p&gt;
&lt;p&gt;I had an incredible year, so I want to share how I got here and what I learned
that might not be obvious when looking from the outside-in.&lt;/p&gt;
&lt;h3 id="getting-the-job"&gt;Getting the job&lt;/h3&gt;
&lt;p&gt;I&amp;#39;m not sure how many times I applied for a job here over the years. It must be
at least six, maybe more.&lt;/p&gt;
&lt;p&gt;Out of those I passed to the final round twice.&lt;/p&gt;
&lt;p&gt;The first round is notoriously difficult, which is quite funny because it&amp;#39;s just
a simple cover letter.&lt;/p&gt;
&lt;p&gt;We write a lot internally. Nearly all communication is written and most of it is
long-form comments and notes. Being able to write is essential.&lt;/p&gt;
&lt;p&gt;I wasn&amp;#39;t a good writer. It took me a few attempts to realize that. But I was
determined to get better at it, so I worked on it.&lt;/p&gt;
&lt;p&gt;The first step to writing better is to read more. Not just technical books,
self-help and similar, but stories, essays, opinion pieces. Learn what&amp;#39;s out
there and expand your vocabulary and literary horizon, then forge your own style
from the things you like.&lt;/p&gt;
&lt;p&gt;A funny side-effect of reading more is that I started to hate &lt;a href="https://stanko.io/corporate-newspeak-v2E1RTcmIUfi"&gt;corporate
newspeak&lt;/a&gt; - it&amp;#39;s so bland and
dull that I immediately tune out as soon as I start to read or listen to it now.
To me it&amp;#39;s like communicating in memes.&lt;/p&gt;
&lt;p&gt;The second step is to write more. I started writing more often on this blog,
which helped with long-form technical writing. But later on I started keeping a
diary. Nothing fancy, I&amp;#39;d just jot down what happened every day and some
thoughts bouncing around in my head - this helped me in general, not just with
writing.&lt;/p&gt;
&lt;p&gt;The third step is to learn how to write a cover letter. This took several
attempts and it boils down to: be yourself.&lt;/p&gt;
&lt;p&gt;I always felt a pressure to make the cover letter sound professional. You know,
the things they tell you in school: serious tone, respect, warm but also not too
personal.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s terrible advice. And I realized that only after I started hating
corporate newspeak. It makes you sound the same as everyone else, and dull on
top of that.&lt;/p&gt;
&lt;p&gt;A cover letter should explain why you want to work there. It&amp;#39;s your chance to
tell your story. It&amp;#39;s supposed to be personal, fun and interesting.&lt;/p&gt;
&lt;p&gt;The second round is a take-home exercise.&lt;/p&gt;
&lt;p&gt;I was never worried about this. For me Rails and the OSS that 37signals puts out
has always been &lt;a href="https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak"&gt;a treasure trove to explore and learn
from&lt;/a&gt; so I was
familiar with their style, and I already spent a lot of time internalizing why
they structure code like they do.&lt;/p&gt;
&lt;p&gt;I could write a whole essay about this, but to keep it short: ergonomics and
domain design are important; the code should read like prose.&lt;/p&gt;
&lt;p&gt;Lastly there is the interview.&lt;/p&gt;
&lt;p&gt;I fumbled the first one. I was unbelievably anxious and over-thought and
over-explained every question; the conversation didn&amp;#39;t flow. But I got good
feedback from it and spent a lot of time improving on it. The second time around
I was relaxed and we had a fun chat.&lt;/p&gt;
&lt;p&gt;Then, one evening I got an email ominously titled &amp;quot;Some news&amp;quot; telling me that
they&amp;#39;d like me to join the company! I was over the moon!&lt;/p&gt;
&lt;h3 id="working-here"&gt;Working here&lt;/h3&gt;
&lt;p&gt;I have a hazy memory of the first few weeks - they felt like a dream. What I
remember the most is reading, and reading a lot.&lt;/p&gt;
&lt;p&gt;As soon as I started working on actual tasks a new feeling hit me: doubt. I
started to second-guess my own work and this thought formed in my mind that I
just lucked out with the hiring process and that someone would soon notice that
my work wasn&amp;#39;t any good.&lt;/p&gt;
&lt;p&gt;This feeling lingered on for months and passed only when I started getting fewer
comments on my PRs.&lt;/p&gt;
&lt;p&gt;It sounds like a curse, and it felt like it, but now I see that it was also a
boon. It motivated me to get better and to learn. I figured that&amp;#39;s the only
thing that would cure that feeling, and it did.&lt;/p&gt;
&lt;p&gt;Then came &lt;a href="https://www.fizzy.do/"&gt;Fizzy&lt;/a&gt;&amp;#39;s launch.&lt;/p&gt;
&lt;p&gt;I got to work on Fizzy as soon as I joined. My first task was to &lt;a href="https://github.com/basecamp/fizzy/pull/857"&gt;add
FizzyAsk&lt;/a&gt; - an LLM-powered chat
where you could tell Fizzy what to do or ask it questions about your data.&lt;/p&gt;
&lt;p&gt;After adding it I barely ever used it. And to my surprise &lt;a href="https://github.com/basecamp/fizzy/pull/1389"&gt;it was completely
scrapped&lt;/a&gt; alongside all AI features
after a month.&lt;/p&gt;
&lt;p&gt;This was the first time I ever saw features get cut - not pivoted, not moved,
not put on hold - after they were built and before they were even released.&lt;/p&gt;
&lt;p&gt;The reason behind the cut was eye-opening: &lt;a href="https://world.hey.com/jorge/delightful-or-nothing-56d86b31"&gt;delightful or
nothing&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Imagine an overgrown garden with hundreds of different plants in it - that&amp;#39;s
what the products of most companies look like to me: an eclectic collection of
ever-growing features. The garden is &amp;quot;designed&amp;quot; by adding whatever plants the
neighbours added to their gardens this week. Everything is overgrown because
nobody has the time to care for so many plants, and even if they do they don&amp;#39;t
know how. The plants that are cared for are overshadowed by the overgrowth.&lt;/p&gt;
&lt;p&gt;By comparison, what I saw with Fizzy was gardening. There was a vision. The
garden had just a few flowers, but all of them were curated to work together and
arranged to be delightful. If something new didn&amp;#39;t work with what was already
there it wouldn&amp;#39;t make the cut, or we&amp;#39;d remove some of the old flowers to make
room for the new ones.&lt;/p&gt;
&lt;p&gt;I talked to a few friends about this and some of them had an interesting
comment: &amp;quot;Isn&amp;#39;t that soul-crushing? You spend so much time building something
just to see it go down the drain.&amp;quot;&lt;/p&gt;
&lt;p&gt;No, it isn&amp;#39;t. In fact, it&amp;#39;s liberating. Not all ideas are good, but you can&amp;#39;t
know that ahead of time. Sometimes you have to try them to understand why they
don&amp;#39;t work. It would be far more soul-draining to have to support an experiment
for years than to admit that it wasn&amp;#39;t very good and bury it right away.&lt;/p&gt;
&lt;p&gt;Whenever I talk about this I think of Buddhist mandalas. These are intricate
artworks made of colored sand that can take weeks to complete. Once they are
done they are admired for a while and then swept up and thrown into a river.
Nothing is permanent.&lt;/p&gt;
&lt;p&gt;Later on I was tasked with building a new magic-link-based auth system. Sounds
easy, right? It wasn&amp;#39;t.&lt;/p&gt;
&lt;p&gt;Fizzy was built on two new in-house gems:
&lt;a href="https://github.com/basecamp/activerecord-tenanted"&gt;activerecord-tenanted&lt;/a&gt; and
&lt;a href="https://www.youtube.com/watch?v=lcved9uEV5U"&gt;beamer&lt;/a&gt;. AR-tenanted separated
each tenant into their own database, and Beamer replicated those SQLite
databases to the whole fleet of servers. The catch was that there had to be a
central identity database for sign-in, but each server was the writer for a few
tenant databases, so you could never write to that database and then the
tenanted database in the same request because the two databases would be on
different servers most of the time.&lt;/p&gt;
&lt;p&gt;As I was untangling that I realized that I was used to working with the latest
release of Ruby and Rails, even with Rails edge. But this was different. We were
ahead of Rails edge. We were building what might become Rails edge at some point
and dogfooding it ourselves to work out the kinks!&lt;/p&gt;
&lt;p&gt;This can sometimes become a challenge. The magic links took two weeks to
implement and I spent a good part of those two weeks wondering if it could even
be done.&lt;/p&gt;
&lt;p&gt;Some of my work on Fizzy did make it into Rails. I added Crockford&amp;#39;s Base32 to
Fizzy for magic link codes and that became
&lt;a href="https://github.com/rails/rails/pull/56288"&gt;&lt;code&gt;SecureRandom.base32&lt;/code&gt;&lt;/a&gt;. Later on I
added passkeys to Fizzy, and &lt;a href="https://github.com/rails/rails/pull/57100"&gt;that might also land in
Rails&lt;/a&gt; as &lt;code&gt;has_passkeys&lt;/code&gt; in Active
Record.&lt;/p&gt;
&lt;p&gt;After the launch we shifted into cycle work. This was the first time I
experienced Shape Up.&lt;/p&gt;
&lt;p&gt;Shape Up has two modes: cycle work and &lt;a href="https://world.hey.com/bb/new-product-mode-48a43806"&gt;new-product
mode&lt;/a&gt;. Cycle work is the
thing most people associate with Shape Up - six week cycles with cooldown
periods in between. In new product mode there&amp;#39;s no fixed cycle, no appetite, no
kick-off, no pitch. It&amp;#39;s like jazz, things are made up as we go.&lt;/p&gt;
&lt;p&gt;After working in both modes I realized that what I thought of as &amp;quot;agile
development&amp;quot; was just waterfall with extra steps.&lt;/p&gt;
&lt;p&gt;Take scrum as an example: two week sprints, task pointing, standups. It sounds
quite similar to Shape Up: six week cycles, appetite, check-ins and hill-charts.&lt;/p&gt;
&lt;p&gt;The magic is in what happens behind the scenes.&lt;/p&gt;
&lt;p&gt;In every company I ever worked at there was something like a &amp;quot;product roadmap&amp;quot; -
a document that laid out each feature that would be added in the fiscal year and
in which order they would be worked on. Then scrum gets slapped on top of that.
So now you are working in cycles, but there is this Gantt chart called the
&amp;quot;roadmap&amp;quot; in the background that suspiciously looks like a waterfall and if
anything slips the cascades become a bit wider. &lt;a href="https://stanko.io/scrum-is-for-time-estimates-not-projects-23ULmuBVSMqo"&gt;Scrum isn&amp;#39;t there to get things
done, it&amp;#39;s there to keep you on
schedule&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In Shape Up the plan is laid out six weeks at a time. It isn&amp;#39;t set in stone, it
can shift; projects are dropped, shortened or extended. The important part is
that after the six weeks are over a new plan is laid out that takes into account
not only what was done in the last six weeks but also what happened in the
world.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://world.hey.com/jason/like-a-squirrel-bd821787"&gt;Jason Fried had an excellent article about this where he compared it to running
like a squirrel&lt;/a&gt;. A
squirrel runs a little, then stops, looks around, picks a new direction, and
then runs some more. As it does that it takes its surroundings into account. It
goes in a direction but it regularly re-evaluates. That&amp;#39;s agile.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve seen so many serendipitous situations where one project became the
groundwork for another that was just the right fit at the moment. You can&amp;#39;t plan
this, you have to see and feel the previous work to form a vision of what you
could build with it next.&lt;/p&gt;
&lt;p&gt;These are just the most eye-opening things I learned.&lt;/p&gt;
&lt;p&gt;Last week was a big week for me because the company decided to keep me! After
the first year everyone goes through a review that asks one question: knowing
what the company knows now, would they hire you again? A huge weight has been
lifted off my shoulders even though I knew the review would go well.&lt;/p&gt;
&lt;p&gt;I&amp;#39;m grateful I get to work alongside and learn from my amazing colleagues at
37signals.&lt;/p&gt;
&lt;p&gt;When I joined I expected a challenge, I wanted to learn and to grow, and that&amp;#39;s
exactly what I got. I&amp;#39;m glad I managed to rise to it. This really was a dream
come true.&lt;/p&gt;&lt;/div&gt;
</content>
    <summary type="html">A year ago I had my first day at 37signals (the people behind Basecamp, HEY and Fizzy).

This was a dream come true for me.

I wanted to work there ever since I read Rework over a decade ago. The books, podcasts and talks that followed showed me that we also share a similar philosophy towards...</summary>
    <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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlIAAABXRUJQVlA4IEYAAADwAQCdASoMABAABABoJbACdADhQSINRgAAyoGwk114sh%2Bq2CdqeicrPI8EKRoGoPXRqfJF9DTPsYwvXHOPXVvGaXXJ%2FAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3f6066;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A winding road on the way to Medvednica" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8969.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A winding road on the way to Medvednica&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/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;/p&gt;
&lt;p&gt;But most of all I love the mountain air.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAADwAQCdASoMABAABABoJbACdAD0MUzofEAA8ZdDRVWcT75kpsZSj6Jff8jjno6nAUP%2BNvaQxP4i6VCAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6c95b6;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Zagreb, as seen from Sljeme, covered with clouds" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_8385.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Zagreb, as seen from Sljeme, covered with clouds&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It smells different, it’s light, relaxing, refreshing, and clean.&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;/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;/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;/p&gt;
&lt;p&gt;I immediately bought a cheap air purifier off IKEA and decided to make an air
quality monitor.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAAAQAgCdASoMABAABABoJZwAAuc%2FRlKJ5KgAAP7wKEwvjdWYsb4vlXN8QJAbPnJAy3IomUm%2F03MkkA%2F5jZxSyo%2BkeLywLvw%2FATRIAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #999a95;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Breadboard with the first prototype of my air quality monitor" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/img_0769.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Breadboard with the first prototype of my air quality monitor&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2096; --attachment-height: 3724; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmAAAABXRUJQVlA4IFQAAADwAQCdASoJABAABABoJZACdADbIp8Gv0AA%2Bp0YuHnQsjXPKAz5hyZobEt%2BlO1EV2FgfKquuam%2FFiaWpmksWXp4xVnicO54mbqLEW4ylRZyfJ5AAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6c7a75;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.webp 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Air Quality Box" loading="lazy" decoding="async" class="attachment__image" width="2096" height="3724" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/776d4c55-fe36-468f-b480-6a6b2cdb9e0e.jpeg/2096w.jpg 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Air Quality Box&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To turn the rat&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2096; --attachment-height: 3724; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAADQAQCdASoJABAABABoJbACdAD0pCf9gAD%2BCctEJPcu8L6xCUM9X0Q4M9ZqPum6MMXUq7lnA87e6gyxqxgW%2FwO4IWfmVoYpClR4vuKAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #54695a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.webp 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The tightly packed inside of the Air Quality Box" loading="lazy" decoding="async" class="attachment__image" width="2096" height="3724" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/ecfac0d9-983b-4eae-9a7b-5cb10d2a527c.jpeg/2096w.jpg 2096w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The tightly packed inside of the Air Quality Box&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/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;/p&gt;
&lt;p&gt;First, its design was very industrial - &lt;em&gt;people either loved it or hated it&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Second, the UI was clunky - &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;/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;/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;figure class="attachment attachment--preview attachment--jpg" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4000; --attachment-height: 1848; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAAcABABoJZQCdAEe9YH9MAD%2BXvX%2FZ6SLVDsg%2BEYxfZ7pjiqQqyaAKfNlDC8%2BY6Q1f0AA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7e7269;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Awair Element that I bought" loading="lazy" decoding="async" class="attachment__image" width="4000" height="1848" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/480w.jpg 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/800w.jpg 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/20250610_174929.jpg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Awair Element that I bought&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I just have one minor annoyance with this interface - you can&amp;#39;t tell if a
measurement is too low or too high at a glance.&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&amp;#39;t terrible, just
annoying.&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&amp;#39;s wrong - no phone, no
fiddling with buttons.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 464; --attachment-height: 534; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2014%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAABQAQCdASoOABAABABoJQBOgCgAAP7tnuHBvf4lP9F4fO3m9wAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #364054;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.webp 464w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Demo of the system tray app" loading="lazy" decoding="async" class="attachment__image" width="464" height="534" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250611155058.png/464w.png 464w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the system tray app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This wouldn&amp;#39;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;/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;/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&amp;#39;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;/p&gt;
&lt;p&gt;Which brings me to AI.&lt;/p&gt;
&lt;p&gt;Until now, I&amp;#39;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;/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&amp;#39;s notice. No more digging through SEO spam for that one useful link - I
get exactly what I need, when I need it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2801; --attachment-height: 1989; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAAAwAQCdASoQAAsABABoJZQAA3AA%2FvA8f6BSk%2F1OQNovgAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #23222b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="ChatGPT and NVIM side by side" loading="lazy" decoding="async" class="attachment__image" width="2801" height="1989" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/480w.png 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/800w.png 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1200w.png 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/1600w.png 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612094946.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;ChatGPT and NVIM side by side&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1884; --attachment-height: 982; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAgABABoJZQAAudZt7gA%2Fu6Xw1xVsS8AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #242430;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/480w.webp 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/800w.webp 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.webp 1884w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The initial prompt given to Claude Code" loading="lazy" decoding="async" class="attachment__image" width="1884" height="982" src="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.png" srcset="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/480w.png 480w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/800w.png 800w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1200w.png 1200w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1600w.png 1600w, https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/pasted-image-20250612103759.png/1884w.png 1884w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The initial prompt given to Claude Code&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And that&amp;#39;s how I - or rather &lt;em&gt;we&lt;/em&gt; - made &lt;a href="https://github.com/monorkin/gnome-desktop-air-monitor"&gt;GNOME Desktop Air
Monitor&lt;/a&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 880; --attachment-height: 716; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAACwAQCdASoQAA0ABABoJZQAAudZpk1gAP4JO7oaT0ylBwuWgSPnExuAMmOOHW5XsC6BfR6T%2FOnL1J5eAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #c7cbcb;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/clean-air-ai-AHcddmIf21lt/screencast-from-2025-06-10-17-24-02-1.jpg/880w.jpg" width="880" height="716"&gt; &lt;source src="https://media.stanko.io/originals/articles/clean-air-ai-AHcddmIf21lt/screencast-from-2025-06-10-17-24-02-1.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of GNOME Desktop Air Monitor&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I didn&amp;#39;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;/p&gt;
&lt;p&gt;&lt;strong&gt;First, the AI is very goal-oriented.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;This isn&amp;#39;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&amp;#39;t care how maintainable, extendable or
readable the solution is - that isn&amp;#39;t its goal.&lt;/p&gt;
&lt;p&gt;To counter that, I had to be very explicit. I had to explain not just &lt;em&gt;what&lt;/em&gt; I
wanted it to do, but also &lt;em&gt;how&lt;/em&gt; I wanted it to do it.&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;/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 &amp;quot;button&amp;quot;, &amp;quot;settingsButton&amp;quot;, &amp;quot;actionList&amp;quot;, etc.
There were three methods - &amp;quot;showIndex&amp;quot;, &amp;quot;showSettings&amp;quot; and &amp;quot;showDevice&amp;quot; - that
were nearly identical, each resets the state of every UI element before
switching screens.&lt;/p&gt;
&lt;p&gt;I had to explicitly ask it: &lt;em&gt;&amp;quot;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&amp;quot;&lt;/em&gt;. It agreed - and refactored the code.&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;/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&amp;#39;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&amp;#39;t
there, then explaining exactly what needs to be done is often more work than
just doing it myself.&lt;/p&gt;
&lt;p&gt;I talked to a friend about this and he said something along the lines of &lt;em&gt;&amp;quot;you
have to treat it like a junior developer&amp;quot;&lt;/em&gt;. But that&amp;#39;s the problem - &lt;em&gt;it isn&amp;#39;t a
junior developer&lt;/em&gt;. It doesn&amp;#39;t learn from its mistakes. You can&amp;#39;t explain to it
in which situation one approach is better than another. It won&amp;#39;t conclude that
it produced garbage or that it&amp;#39;s painting itself into a corner - as long as the
goal was &lt;em&gt;technically&lt;/em&gt; achieved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, it doesn&amp;#39;t understand the sunken cost fallacy.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The AI will almost never give up on a solution. It will almost always build on
top of what already exists. It doesn&amp;#39;t question, it doesn&amp;#39;t suggest, it just
marches on. In that regard, it&amp;#39;s similar to a junior developer.&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&amp;#39;t work, and
that it should try something else.&lt;/p&gt;
&lt;p&gt;I didn’t really see this as a serious issue until my girlfriend - who can&amp;#39;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;/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&amp;#39;t know what it had to do - and didn&amp;#39;t tell it explicitly - we&amp;#39;d still
be spinning around in circles, trying every hack known to man or machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third, it doesn&amp;#39;t generalize.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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&amp;#39;d expect better.&lt;/p&gt;
&lt;p&gt;But enough of the negatives. I also discovered some strengths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s brilliant at writing documentation from existing code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s decent at writing tests.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s excellent for scaffolding.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;All in all, I learned that I love programming as much as I love solving
problems.&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;/p&gt;
&lt;p&gt;Working &lt;em&gt;with&lt;/em&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;/p&gt;
&lt;p&gt;Back then I was in my late teens and the world was &lt;em&gt;(or at least felt)&lt;/em&gt;
completely different. Out of nostalgia, I spent some time exploring the source
code and even got it to run &lt;em&gt;(for the most part)&lt;/em&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;/p&gt;
&lt;h3 id="what-was-the-app-for"&gt;What was the app for?&lt;/h3&gt;
&lt;p&gt;My friends and I used to play Call of Duty 2 together after school - &lt;em&gt;seems like
the world hasn&amp;#39;t changed that much after all&lt;/em&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;/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;em&gt;(a demo is a file that allows you to replay
a game and spectate)&lt;/em&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;/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;/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;/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;/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&amp;#39;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;em&gt;because I didn&amp;#39;t know that databases existed&lt;/em&gt; - kept corrupting files forcing
me to recreate everything every now and then.&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;/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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1192; --attachment-height: 983; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADQAQCdASoQAA0ABABoJaQAAphO2RgYAAD8tDN%2BVgMDg6zpEo7qsRGpp87P31IBEyDWNKfnI2Yrq07IOhQcTBn4GGoAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #646464;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.webp 1192w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The website, mostly working" loading="lazy" decoding="async" class="attachment__image" width="1192" height="983" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082554.png/1192w.png 1192w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The website, mostly working&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It even had a visitor counter - this was the Google Analytics of the time.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 327; --attachment-height: 335; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoQABAABABoJaQAAugv4rb%2FgAD%2B8QwmpenJlXFuDQBos6ejXL%2Bu1UAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f1f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.webp 327w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The visitor counter that was in the bottom-right corner" loading="lazy" decoding="async" class="attachment__image" width="327" height="335" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082935.png/327w.png 327w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The visitor counter that was in the bottom-right corner&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This was written for PHP 4 and I&amp;#39;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&amp;#39;m honestly amazed that I only had to do a handful of
changes to get the app to boot with PHP 7.&lt;/p&gt;
&lt;p&gt;Digging through the code made me remember how &lt;strong&gt;different the Web was back
then&lt;/strong&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&amp;#39;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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;DOCTYPE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; PUBLIC&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;-//W3C//DTD XHTML 1.0 Transitional//EN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/1999/xhtml&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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&amp;#39;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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;meta&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; http-equiv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Content-Type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text/html; charset=iso-8859-1&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 721; --attachment-height: 868; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAACwAQCdASoNABAABABoJZQAAudFzMOgAP7r9JgNTkrPJ2QAScgBZAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #292a32;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.webp 721w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="DOM of the website" loading="lazy" decoding="async" class="attachment__image" width="721" height="868" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523082715.png/721w.png 721w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;DOM of the website&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;t exist - so they were mostly used as &lt;em&gt;DIV&lt;/em&gt;iders. And the nice thing about
tables is that you can set a fixed width to every cell using the &lt;em&gt;width&lt;/em&gt;
attribute - this was the only way you could do templates back then.&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&amp;#39;d make an HTML table that puts the puzzle back
together. You&amp;#39;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&amp;#39;t fit into a single table you&amp;#39;d nest HTML tables to get the
layout you want.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1095; --attachment-height: 885; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAADQAQCdASoQAA0ABABoJaQAAudsj0fdsAD%2B8%2B1jiu0VWmA2AjR5HiQ7L3ZCdCYKaJTHx3o6QAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f0f0f0;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.webp 1095w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="An example of how slicing worked" loading="lazy" decoding="async" class="attachment__image" width="1095" height="885" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/slicing.png/1095w.png 1095w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;An example of how slicing worked&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;s 100+ Mbps speeds, were
barely able to provide a meager 2 Mbps. This also meant that you&amp;#39;d often have to
slice something in such a way that seams and tiling wasn&amp;#39;t obvious.&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;/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&amp;#39;t a thing - the iPhone had just come out and
there was literally no way to detect the screen size.&lt;/p&gt;
&lt;p&gt;This produces hilarious results on today&amp;#39;s monitors. E.g. this is what the site
looks like on my ultra-wide.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4992; --attachment-height: 1002; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%203%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAADQAQCdASoQAAMABABoJaQAAxdCgjfIwAD2p9yAIA%2B%2F%2Fwwt7JPVXMENgYAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7f7f7f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/480w.webp 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/800w.webp 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="What it looks like on a 40&amp;quot; ultra-wide" loading="lazy" decoding="async" class="attachment__image" width="4992" height="1002" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/480w.png 480w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/800w.png 800w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1200w.png 1200w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/1600w.png 1600w, https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523134844.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;What it looks like on a 40&amp;quot; ultra-wide&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And on my iPhone 14&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 424; --attachment-height: 927; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjwAAABXRUJQVlA4IDAAAACwAQCdASoHABAABABoJaQAAvd7jTdAAPulese%2FmGxLUn4RG64%2Fseec5hsY8pAAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3a3a3a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.webp 424w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="What it looks like on an iPhone 14" loading="lazy" decoding="async" class="attachment__image" width="424" height="927" src="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png" srcset="https://media.stanko.io/variants/articles/my-last-php-app-how-i-fell-for-ruby-pcaqgWmybRLN/pasted-image-20250523135013.png/424w.png 424w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;What it looks like on an iPhone 14&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I can barely read the text, and I&amp;#39;ll need a toothpick to click on a link.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=qObzgUfCl28"&gt;&lt;strong&gt;Ruby, Ruby, Ruby, Ruby&lt;/strong&gt;&lt;/a&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;/p&gt;
&lt;p&gt;I decided to give Rails a try. webSPELL wasn&amp;#39;t a good fit for a real estate
website, and I was ready for something new.&lt;/p&gt;
&lt;p&gt;Something about Ruby just clicked. The code read like a story. Ruby&amp;#39;s syntax
felt natural and obvious in a way PHP never had. Suddenly programming wasn&amp;#39;t
just about hacking things together - it was expressive, joyful, and beautiful.&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 class="highlight" data-language="php"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;implode&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; [$first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; $last_name]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;to how it looked in Ruby:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; last_name]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Ruby felt like I was talking to the data. I&amp;#39;d say &amp;quot;Hey Array, join your elements
together with this separator&amp;quot;. 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 class="highlight" data-language="php"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;$user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;safe_query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;SELECT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; FROM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;PREFIX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;users WHERE first_name = &amp;#39;John&amp;#39; LIMIT 1&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;vs.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by_first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;John&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&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;/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&amp;#39;ve been fortunate enough to
work with Ruby and with Rails on every job I&amp;#39;ve had since.&lt;/p&gt;
&lt;p&gt;That dusty old PHP app didn&amp;#39;t age well, but I&amp;#39;m glad I found it.&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;/p&gt;
&lt;p&gt;I didn&amp;#39;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 class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;/bin/bash&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -c&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -fsSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&lt;/span&gt;&lt;/span&gt;&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;h3 id="figuring-out-how-once-works"&gt;Figuring out how Once works&lt;/h3&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 class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -fsSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Installing the ONCE command...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;command_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;https://auth.once.com/install/1111-1111-1111-1111?arch=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;arch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;amp;platform=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -s&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; $command_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-tmp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-tmp&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; chmod&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; +x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [[ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;$OSTYPE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;darwin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; ]]; &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Don&amp;#39;t use sudo on macOS; Docker Desktop is likely to be running as the user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  /usr/local/bin/once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;fi&lt;/span&gt;&lt;/span&gt;&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;em&gt;strings&lt;/em&gt;
command&lt;/a&gt; - it returns
all things that look like C strings from a given file.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.masterSecretLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.extendedMasterSecretLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.keyExpansionLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.clientFinishedLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;crypto/tls.serverFinishedLabel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart..inittask &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.ErrMessageTooLarge &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartFiles &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartMaxParts &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.emptyParams &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.multipartMaxHeaders &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;mime/multipart.quoteEscaper &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.errMessageTooLarge &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.colon net/textproto.nl &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.commonHeader &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net/textproto.commonHeaderOnce &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net..inittask &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.rfc6724policyTable &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.confOnce &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.confVal &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;net.netdns &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&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 class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.init &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.map.init.0 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.GetActiveHelpConfig &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.activeHelpEnvVar &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.legacyArgs &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.(*Command).HasSubCommands &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.(*Command).HasParent &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.NoArgs &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;github.com/spf13/cobra.writePreamble &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;... &lt;/span&gt;&lt;/span&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 class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;once-cli/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;├── cmd/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ └── once/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ └── main.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;│ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;└── internal/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── archiver/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── compressor.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── uncompressor.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── cmd/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update_off.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── auto_update_on.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data_backup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── data_restore.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── password.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── password_reset.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── root.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── setup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── start.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── status.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── stop.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── update.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── util.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── config/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── config.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── environment.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── verifier.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── docker/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── docker.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    ├── networking/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── dns_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ ├── network_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ └── ssl_traffic_check.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    │ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    └── tui/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── backup/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── backup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── components/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── task_list.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── question/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── question.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── restore/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── restore.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── runner/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── runner.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      ├── setup/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ ├── installer.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ ├── registration.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      │ └── setup.go &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      └── util.go &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And I found three URLs&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;https://auth.once.com/install/ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;https://auth.once.com/verify/%d &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;registry.once.com &lt;/span&gt;&lt;/span&gt;&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;em&gt;once-cli/internal/cmd/password_reset.go&lt;/em&gt;) because it
interpolates a number at the end (&lt;a href="https://pkg.go.dev/fmt"&gt;that&amp;#39;s the &lt;em&gt;%d&lt;/em&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 class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// contents of /root/.config/once/config.json&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stanko@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;chat.rubyzg.org&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_VALIDATION_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;secret_key_base&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_SECRET_KEY_BASE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;vapid_private_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PRIVATE_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;vapid_public_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PUBLIC_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;storage_location&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/var/once/campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;cron_hour&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;once_binary_etag&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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 &amp;quot;token&amp;quot;. My guess is that it uses the license key as an auth
token with the registry to pull updates. That&amp;#39;s a common way to do token auth
with Docker Registries. Let&amp;#39;s try that.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; login&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  --username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;stanko@example.com&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  --password&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;1111-1111-1111-1111&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;#39;https://registry.once.com&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Succeeded&lt;/span&gt;&lt;/span&gt;&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&amp;#39;t asked during set up. I&amp;#39;m guessing that the install URL is in the binary
because it can respond with &lt;em&gt;HTML / text&lt;/em&gt; for the install script but it can
probably also respond to &lt;em&gt;JSON&lt;/em&gt;. Let&amp;#39;s try that&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; https://auth.once.com/install/1111-1111-1111-1111.json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; jq&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stanko@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; null,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;lt;some-base64-string&amp;gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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&amp;#39;t even look like
unicode - so I assume that it&amp;#39;s either completely random or an encrypted
payload. Maybe that&amp;#39;s what the verify endpoint is for?&lt;/p&gt;
&lt;p&gt;My guess is that the token is used to update the &amp;quot;ssl_domain&amp;quot; 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;h3 id="how-once-works"&gt;How Once works&lt;/h3&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;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 911; --attachment-height: 1058; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2014%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADQAQCdASoOABAABABoJaScC2B8AAAeQAD%2B8PZccsKfHy%2BjdTP7%2F5k1Am7rna5e%2BJf7HMruIWzCb3DWsHN3e7L67qJAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.webp 911w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="An overview of Once" loading="lazy" decoding="async" class="attachment__image" width="911" height="1058" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.png 911w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;An overview of Once&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;T&lt;em&gt;he Auth server&lt;/em&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;em&gt;The CLI&lt;/em&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;em&gt;The private Docker Registry&lt;/em&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;h3 id="building-twice"&gt;Building Twice&lt;/h3&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 &amp;quot;Twice&amp;quot; as a joke on the fact that it&amp;#39;s a clone
of Once - it&amp;#39;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&amp;#39;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&amp;#39;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&amp;#39;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&amp;#39;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&amp;#39;d
probably go with Rust as I have more experience with it, or I&amp;#39;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;em&gt;setup&lt;/em&gt; command that accepts one argument - &lt;em&gt;the license key&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;go run cmd/twice/main.go setup --help&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Install all the necessary dependencies to run a product and then installs the product associated with the given license key&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Usage:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  twice setup &amp;lt;license-key&amp;gt; [flags]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Flags:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  -h, --help   help for setup&lt;/span&gt;&lt;/span&gt;&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1890; --attachment-height: 1012; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAABQAQCdASoQAAkABABoJZwABAAAAP7wnkRJeAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1b1b26;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_tui_demo.jpg/1200w.jpg" width="1890" height="1012"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_tui_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Twice TUI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I spent a couple of hours working on it, I got quite far, but in the end I
realized the TUI didn&amp;#39;t matter. It was just slowing me down, so I ditched it and
replaced it with good old &lt;em&gt;puts&lt;/em&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1892; --attachment-height: 1012; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAkABABoJZQAAud7WpAA%2Fu8cE%2FuuQl4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1c1c27;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_demo.jpg/1200w.jpg" width="1892" height="1012"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Twice CLI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;I like the version with the TUI more - it feels refined - and I think I&amp;#39;ll
revisit it once I&amp;#39;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&amp;#39;t implement any UI and relied heavily on
&lt;em&gt;&amp;quot;bin/rails db:fixtures:load&amp;quot;&lt;/em&gt; to give me some data to work with. These
endpoints were plain Rails controllers that rendered JSON.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1435; --attachment-height: 998; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAACwAQCdASoQAAsABABoJaQAAl1%2FZQ4AAP7t4ZkK%2B%2BQWpQL6SgJvPcakBbzUu29whjfjWDVIAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.webp 1435w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The models I had for Twice" loading="lazy" decoding="async" class="attachment__image" width="1435" height="998" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.png 1435w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The models I had for Twice&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;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&amp;#39;s written in Rails so I tried Portus,
but I couldn&amp;#39;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&amp;#39;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;All I had to do to get the registry working is to generate a self-signed
certificate&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;openssl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; req&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -x509&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -newkey&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; rsa:4096&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -nodes&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -keyout&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ./auth.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -out&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ./auth.crt&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -days&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 365&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -subj&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/CN=registry-auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;basicConstraints=critical,CA:true&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;keyUsage=keyCertSign,digitalSignature&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    -addext&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;subjectAltName=DNS:registry-auth&amp;quot;&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Use token auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;token&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Use `http://localhost:3000/registry/auth` as the auth server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_REALM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;http://localhost:3000/registry/auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This isn&amp;#39;t too important&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_SERVICE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This has to match what you return from the auth server and what&amp;#39;s in the certificate&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_ISSUER&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;registry-auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This specifies which certificate to use to verify tokens&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;/certs/auth.pem&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s it. Here is a Docker compose file for it&lt;/p&gt;
&lt;pre class="highlight" data-language="yaml"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;services&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  registry&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    image&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; registry:2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # On Linux&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    network_mode&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; host&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # On Mac&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ports:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    #   - 5000:5000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    environment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH=token&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_SERVICE=registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_ISSUER=registry-auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pem&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;REGISTRY_STORAGE_DELETE_ENABLED=true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    volumes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;./registry/tmp/registry_data:/var/lib/registry&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      - &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;./registry/certs:/certs:ro&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;match &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;/registry/auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; to:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry#auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; as:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :registry_auth&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; via:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; %i[ get post ]&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;signed.jwt.token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;expires_in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 300&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;issued_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;2025-05-15T16:55:23Z&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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 class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;iss&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry-auth&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Has to match the value set in REGISTRY_AUTH_TOKEN_ISSUER&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;sub&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;stankoexample.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Username of the person logging in&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;aud&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;registry&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // Has to match REGISTRY_AUTH_TOKEN_SERVICE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;exp&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747329069&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;nbf&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747328784&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;iat&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1747328784&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;jti&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;2aae6c35c94fcfb415dbe95f408b9ce91ee846ed&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;access&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;repository&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  &amp;quot;actions&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;pull&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;KID&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; OpenSSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Digest&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SHA256&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PRIVATE_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;public_key&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_der&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;hexdigest&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;upcase&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;scan&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/.{1,2}/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;:&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;X5C&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Base64&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strict_encode64&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_der&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Putting it all together, the controller looks like this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; auth&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  authenticate_or_request_with_http_basic &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;email&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; license_key&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;email_address:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; email)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    deny_access &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;and&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    license &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;licenses&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by_key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(license_key)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    token &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;generate_registry_access_token_to_product&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; service:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:service&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    deny_access &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;and&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      expires_in:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      issued_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;issued_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;iso8601&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    render(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;json:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :ok&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1422; --attachment-height: 1120; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAA0ABABoJZwAAu1%2FHLJgAAD%2B8BoUa9UtjFZnhBk7g9b17%2BZCAgBOrIjdXBp8dIPDOgAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ececec;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.webp 1422w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The models in Twice after adding developers" loading="lazy" decoding="async" class="attachment__image" width="1422" height="1120" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.png 1422w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The models in Twice after adding developers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;Second, the install endpoint. This was straight forward&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config.routes.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;get &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;/install/:license_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; to:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;install#install&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; as:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :install&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; defaults:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;format:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :text&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# app/controllers/install_controller.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; install&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  respond_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    format&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And then I just had to create two separate view files. One for the script in
&lt;em&gt;app/views/install/install.text.erb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/usr/bin/env bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Installing the TWICE command...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;echo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;command_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&amp;lt;%= install_download_url %&amp;gt;?arch=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -m&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;amp;platform=$(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;uname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;$command_path&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -o&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .twice_tmp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .twice_tmp&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; chmod&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; +x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [[ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;$OSTYPE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;darwin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; ]]; &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Don&amp;#39;t use sudo on macOS; Docker Desktop is likely to be running as the user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  /usr/local/bin/twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;%=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; @license.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; %&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /usr/local/bin/twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;%=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; @license.key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; %&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;fi&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And another in &lt;em&gt;app/views/install/install.json.jbuilder&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;key&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;key&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;owner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(@license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;owner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :email_address&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(@license&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;product&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :repository&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;registry&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;REGISTRY_URL&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;localhost:5000&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Notice that I strayed a bit from 37signals&amp;#39; 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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; download&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  platform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; sanitize_file_name_part(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:platform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  arch &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; sanitize_file_name_part(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:arch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  filename &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;twice&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; platform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; arch]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;compact&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;-&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  file_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Pathname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;root&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;storage&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; filename))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; file_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;exist?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    send_file file_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;application/octet-stream&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; filename:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; filename&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    head &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:not_found&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It assumes that the requested binary will be called
&lt;em&gt;&amp;quot;twice-#{platform}-#{arch}&amp;quot;&lt;/em&gt;, then it goes and checks if such a file exists
in the &lt;em&gt;storage&lt;/em&gt; directory, and if it does it sends it.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 888; --attachment-height: 378; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAADQAQCdASoQAAcABABoJZwAAveFlU7YAAD%2B7pJrntx6%2FRVwWSCgAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #2c2c2d;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.webp 888w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The download endpoint in action" loading="lazy" decoding="async" class="attachment__image" width="888" height="378" src="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png" srcset="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/480w.png 480w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/800w.png 800w, https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.png 888w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The download endpoint in action&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;In production this won&amp;#39;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;Third, I added authentication using the new auth generator in Rails 8, and a UI
for managing customers, developers, products and licenses.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1194; --attachment-height: 984; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAACwAQCdASoQAA0ABABoJaQAAudFy7wAAP7VF9v59pwAQVIEnAUBTmFSV1bfNAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #eaeaea;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/auth_ui_demo.jpg/1194w.jpg" width="1194" height="984"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/auth_ui_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the Auth app UI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And that was it. Twice was done. Here is the demo&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1892; --attachment-height: 996; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACQAQCdASoQAAgABABoJZwAAuTqYAAA%2Fux0u09PIl4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f2a;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_demo.jpg/1200w.jpg" width="1892" height="996"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_demo.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of Twice installing an app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="some-loose-thoughts"&gt;Some loose thoughts&lt;/h3&gt;
&lt;p&gt;When writing about building Simpliki I left out an important part - what I
intentionally didn&amp;#39;t do.&lt;/p&gt;
&lt;p&gt;For Twice, I intentionally didn&amp;#39;t implement domain name tracking as I think that
it&amp;#39;s not important. It can&amp;#39;t prevent people from running the docker container
how and where they want. It would add complexity and I don&amp;#39;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&amp;#39;t implement in Twice. I intend to return to that at some
point. But they aren&amp;#39;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&amp;#39;t play well with other
apps or &lt;a href="https://kamal-deploy.org/"&gt;Kamal&lt;/a&gt;. This isn&amp;#39;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&amp;#39;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&amp;#39;s also why the Twice CLI doesn&amp;#39;t keep a config file - I&amp;#39;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&amp;#39;t important for now. And I&amp;#39;d like to be able to
install an app from different Twice instances on the same server. Something like&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111@auth.foo.com&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;twice&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 2222-2222-2222-2222@auth.bar.com&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This would make it more versatile - but, again, that&amp;#39;s way beyond the scope of
creating a clone to learn how Once works.&lt;/p&gt;
&lt;p&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&amp;#39;t sit right
with me. If you didn&amp;#39;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&amp;#39;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&amp;#39;t expect.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 638; --attachment-height: 664; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABQAQCdASoPABAABABoJZwABAAAAP7vLnwwL1HAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #303236;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/screencast-from-2025-05-06-10-58-48-1-web.jpg/638w.jpg" width="638" height="664"&gt; &lt;source src="https://media.stanko.io/originals/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/screencast-from-2025-05-06-10-58-48-1-web.mp4" type="video/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;h3 id="goal"&gt;Goal&lt;/h3&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;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;/p&gt;
&lt;h3 id="what-is-mcp"&gt;What is MCP?&lt;/h3&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&amp;#39;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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1305; --attachment-height: 1220; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2015%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoQAA8ABABoJaQAAuQry6pygAD%2B9hKksUnbgDBhyNHS%2BWto5Zgo7cRV1d9jRBw%2BOe8HgmAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f1f1f1;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/800w.webp 800w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.webp 1305w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Overview of how the MCP client and server interact" loading="lazy" decoding="async" class="attachment__image" width="1305" height="1220" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/800w.png 800w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1200w.png 1200w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/quick-sheets-page-33.png/1305w.png 1305w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Overview of how the MCP client and server interact&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;E.g. You could ask a client to &lt;em&gt;&amp;quot;Find a file called meeting_notes.txt&amp;quot;&lt;/em&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&amp;#39;s connected to.&lt;/p&gt;
&lt;p&gt;The LLM might respond with something like &lt;em&gt;&amp;quot;{action: &amp;#39;tool_call&amp;#39;, tool:
&amp;#39;find_file&amp;#39;, arguments: [&amp;#39;meeting_notes.txt&amp;#39;]}&amp;quot;&lt;/em&gt; and the client would go and
make a request to an MCP server that exposes a tool called find_file.&lt;/p&gt;
&lt;p&gt;The server then responds with something like &lt;em&gt;&amp;quot;{path:
&amp;#39;/home/user/meeting_notes.txt&amp;#39;, contents: &amp;#39;...&amp;#39;}&amp;quot;&lt;/em&gt; and the client passes that
on to the LLM which then decides what to do with that information.&lt;/p&gt;
&lt;h3 id="mcp-and-rails"&gt;MCP and Rails&lt;/h3&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 class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bundle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; add&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; fast-mcp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; generate&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; fast_mcp:install&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This gave me two new directories - &lt;em&gt;app/tools&lt;/em&gt; and &lt;em&gt;app/resources&lt;/em&gt; - and a class
name conflict.&lt;/p&gt;
&lt;p&gt;The resources directory contains an &lt;em&gt;ApplicationResource&lt;/em&gt; class, but our app
already has a different &lt;em&gt;ApplicationResource&lt;/em&gt; class in &lt;em&gt;app/models&lt;/em&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;em&gt;MCP&lt;/em&gt; namespace.&lt;/p&gt;
&lt;p&gt;First I moved the two new directories into an &lt;em&gt;mcp&lt;/em&gt; directory&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mkdir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/tools&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp/tools&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;mv&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/resources&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; app/mcp/resources&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But now the classes were namespaced with &lt;em&gt;Tools&lt;/em&gt; and &lt;em&gt;Resources&lt;/em&gt; instead of with
&lt;em&gt;MCP&lt;/em&gt; because Zeitwerk treats each directory in app/ as a root directory. To
reconfigure Zeitwerk I added the following to &lt;em&gt;config/initializers/zeitwerk.rb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/zeitwerk.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;autoloaders&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;main&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;tap&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;autoloader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Dir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;glob&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;root&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;app/mcp/*&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;directory?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(file)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    autoloader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;push_dir&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; namespace:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That tells Zeitwerk to treat every directory in &lt;em&gt;app/mcp&lt;/em&gt; as a root directory
with an &lt;em&gt;MCP&lt;/em&gt; namespace.&lt;/p&gt;
&lt;p&gt;Now &lt;em&gt;app/mcp/resources/application_resource.rb&lt;/em&gt; defines
&lt;em&gt;MCP::ApplicationResource&lt;/em&gt; instead of &lt;em&gt;Resources::ApplicationResource&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;With that out of the way I could finally run my server&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; s&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="the-client"&gt;The Client&lt;/h3&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 class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;curl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; http://localhost:3000/mcp/sse&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Host localhost:3000 was resolved.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; IPv6: ::1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; IPv4: 127.0.0.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;   Trying [::1]:3000...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Connected to localhost (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) port 3000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; using HTTP/1.x&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; GET /mcp/sse HTTP/1.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Host: localhost:3000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; User-Agent: curl/8.13.0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Accept: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Request completely sent off&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; HTTP/1.1 200 OK&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Content-Type: text/event-stream&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Cache-Control: no-cache, no-store, must-revalidate&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Connection: keep-alive&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; X-Accel-Buffering: no&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Origin: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Methods: GET, OPTIONS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Allow-Headers: Content-Type&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Access-Control-Max-Age: 86400&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Keep-Alive: timeout&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;600&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Pragma: no-cache&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; Expires: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; no chunk, no close, no size. Assume close to signal &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; SSE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; established&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;event:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; endpoint&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /mcp/messages?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;retry:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 4&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; keep-alive&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;event:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; message&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; {&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;jsonrpc&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;2.0&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;method&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ping&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;:715239&lt;/span&gt;&lt;/span&gt;&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&amp;#39;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&amp;#39;t available on Linux even
though it&amp;#39;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&amp;#39;s available on Linux.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;paru&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; -Sy&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; zed-preview-bin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;zeditor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;(To get MCP support you have to join the&lt;/em&gt; &lt;a href="https://zed.dev/ai/agent"&gt;&lt;em&gt;Agentic Editing
Beta&lt;/em&gt;&lt;/a&gt;&lt;em&gt;)&lt;/em&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&amp;#39;t support connecting to remote
servers. This is a planned feature that&amp;#39;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 class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;npx&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; mcp-remote@0.0.22&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; http://localhost:3000/mcp/sse&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --allow-http&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --skip-browser-auth&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;Can you greet a User &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;with&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ID&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 636166899&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To my surprise it didn&amp;#39;t respond with a greeting but started digging through the
codebase&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 635; --attachment-height: 348; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAABQAQCdASoQAAkABABoJZwABAAAAP7usVuY855tuU8Up%2BjujEbgIAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.webp 635w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude digging through the code base to figure out how to greet a user" loading="lazy" decoding="async" class="attachment__image" width="635" height="348" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505124833.png/635w.png 635w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;
&lt;p&gt;After some digging around I soon discovered that, while the server was running,
it wasn&amp;#39;t advertising any tools.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 622; --attachment-height: 148; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAADQAQCdASoQAAQABABoJZwC7AEO%2BnboAAD%2B4tZy45PhKcEv8G4AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #393e4a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.webp 622w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="My MCP server advertising 0 tools to the client" loading="lazy" decoding="async" class="attachment__image" width="622" height="148" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505125117.png/622w.png 622w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;My MCP server advertising 0 tools to the client&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;register_tools&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ApplicationTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;register_resources&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MCP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ApplicationResource&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That method returns all classes that inherit from a given class, but a caveat of
that method is that it returns only descendants that have been loaded so far.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Foo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Bar&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Foo&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Foo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;descendants&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [Bar]&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Since, in development, Rails disabled eager loading no descendants of
&lt;em&gt;ApplicationTool&lt;/em&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&amp;#39;t a big deal for me.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/environment/development.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;application&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Eager load all classes if the EAGER_LOAD ENV is set&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;eager_load&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; !!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;EAGER_LOAD&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;So all I had to do is restart the server with&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;EAGER_LOAD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; s&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I entered the same prompt again, and... it didn&amp;#39;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&amp;#39;d have to
create a new Profile&lt;/p&gt;
&lt;div class="attachment-gallery attachment-gallery--3"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 334; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAABQAQCdASoQAAgABABoJZwABAAAAP7vVjRHi4DZksorV4eQAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #30313c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.webp 636w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Location of the Profile tab in Zed" loading="lazy" decoding="async" class="attachment__image" width="636" height="334" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130656.png/636w.png 636w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Location of the Profile tab in Zed&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 745; --attachment-height: 339; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACQAQCdASoQAAcABABoJZwAAuP2ZsAA%2FuSqQE4chaVtYeQAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #353540;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.webp 745w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="The Configure Profile section of the Profiles tab in Zed" loading="lazy" decoding="async" class="attachment__image" width="745" height="339" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130722.png/745w.png 745w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Configure Profile section of the Profiles tab in Zed&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 643; --attachment-height: 251; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABwAQCdASoQAAYABABoJZwC7AFAAAD%2B7hNzxEuugQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #353a45;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.webp 643w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="The new Profile button in Zed" loading="lazy" decoding="async" class="attachment__image" width="643" height="251" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130837.png/643w.png 643w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The new Profile button in Zed&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 596; --attachment-height: 141; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABQAQCdASoQAAQABABoJZwABAAAAP7uE24ApNtQgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373843;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.webp 596w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naming a new profile in Zed" loading="lazy" decoding="async" class="attachment__image" width="596" height="141" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130903.png/596w.png 596w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Naming a new profile in Zed&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And then configure that Profile&amp;#39;s tools to include only my MCP server&amp;#39;s tools.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 623; --attachment-height: 173; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABwAQCdASoQAAQABABoJZQC7AFAAAD%2B7dIBwdTAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #3a3e4b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.webp 623w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Configuring tools for the new profile" loading="lazy" decoding="async" class="attachment__image" width="623" height="173" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505130943.png/623w.png 623w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Configuring tools for the new profile&lt;/figcaption&gt; &lt;/figure&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 588; --attachment-height: 431; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2012%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoQAAwABABoJZwABAAAAP7uUCFK%2FGI3AQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #363944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.webp 588w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Enabling individual tools from different MCP servers" loading="lazy" decoding="async" class="attachment__image" width="588" height="431" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131014.png/588w.png 588w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Enabling individual tools from different MCP servers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;With that configured I tried the prompt again and this time it worked!&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 372; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoQAAkABABoJZwABAAAAP7vLmupwUvysPIAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #31323d;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.webp 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude sending a greeting to a User through the MCP server" loading="lazy" decoding="async" class="attachment__image" width="636" height="372" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505131112.png/636w.png 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;h3 id="building-a-tool"&gt;Building a tool&lt;/h3&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchBuildingsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_buildings&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Searches for buildings&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_ids&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;IDs of the Buildings to look up&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The search query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_ids: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Building&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; building_ids&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;present?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; buildings&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; building_ids)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;present?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        buildings &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; buildings&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;name LIKE :query OR handle LIKE :query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; query:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;%&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{query}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;%&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buildings&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;limit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;50&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :desc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;only:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :handle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :address&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;that worked quite well&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 633; --attachment-height: 311; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACwAQCdASoQAAgABABoJZwAAudV78eAAP7kBNaKV3DT4jcBAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #383944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.webp 633w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Demo of the Building search tool" loading="lazy" decoding="async" class="attachment__image" width="633" height="311" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134334.png/633w.png 633w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the Building search tool&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then I wanted to implement Access Log search. But to search Access Logs you need
a building ID. I wasn&amp;#39;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&amp;#39;t find a definitive answer online so I hoped for
the best and implemented the following&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchAccessLogsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_access_logs&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Searches through Access Logs and returns the fist 25 results&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      required(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;filled&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ID of the Building that we are searching in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The search query&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_id: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      building &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; begin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Building&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(building_id)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      rescue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;RecordNotFound&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Building not found&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  building&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;query:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;logged_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :desc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;limit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;25&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;only:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :logged_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :description&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :access_point_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	      :status&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	    ])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 636; --attachment-height: 1361; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAABQAQCdASoHABAABABoJZwABAAAAP7ur7bgSQhibahAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373843;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.webp 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude reusing previous result to solve new problems" loading="lazy" decoding="async" class="attachment__image" width="636" height="1361" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505134942.png/636w.png 636w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Claude reusing previous result to solve new problems&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I was curious if it could actually combine tools so I started a new chat and
asked the same question.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 634; --attachment-height: 1096; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAABQAQCdASoJABAABABoJZwABAAAAP7utUB8RqzbGom2oQAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #373944;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/480w.webp 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.webp 634w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Claude combining multiple tools in a single response to solve a problem" loading="lazy" decoding="async" class="attachment__image" width="634" height="1096" src="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png" srcset="https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/480w.png 480w, https://media.stanko.io/variants/articles/adding-mcp-to-a-rails-app-NG9zkX3dyPq1/pasted-image-20250505135346.png/634w.png 634w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;h3 id="takeaways"&gt;Takeaways&lt;/h3&gt;
&lt;p&gt;MCP is a very &amp;quot;loose&amp;quot; protocol. It does allow you to specify what inputs your
tool has, which are required and which are optional, but it&amp;#39;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&amp;#39;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 &amp;quot;Can you give me all access logs created in the
last hour in the building Crimson?&amp;quot;.&lt;/p&gt;
&lt;p&gt;I expected it to get all the access logs and then filter them on its own, but
that&amp;#39;s not what happened. Instead it passed &lt;em&gt;&amp;quot;logged_at:&amp;gt;=2025-05-05T07:36:31&amp;quot;&lt;/em&gt;
as the query. And, honestly, I can&amp;#39;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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MCP&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SearchAccessLogsTool&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationTool&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    tool_name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;search_access_logs&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    description &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;&amp;lt;&amp;lt;~MD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Searches for Access Logs in a given building and returns&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      a JSON string containing an array of up to 25 objects.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      The list is sorted in descending order by the time &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      the access log was logged.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Inputs:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - building_id [Integer] (the ID of the building in which to search for access logs)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&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;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_after [String, null] (An ISO8601 timestamp of the earliest time a log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_before [String, null] (An ISO8601 timestamp of the latest time a log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - statuses [Array[String], null] (A comma-separated list of statuses)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Output:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - id [Integer] (The ID of the access log record)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - logged_at [String] (ISO8601 timestamp of the time the log was recorded)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - grantor [String, null] (Name of the person that granted this access, a null value means that the system granted the access)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - description [String] (A description of what happened)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - access_point_id [Integer] (ID of the access point that was accessed)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - status [String] (The access point&amp;#39;s state after this access)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      Statuses:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - open - means that the access point was unlocked and that someone opened it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - closed - means that the access point was unlocked, opened and then closed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - left_ajar - means that the access point was unlocked, and then left open for more than 1min&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      - denied - the access point never unlocked&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;      If the building couldn&amp;#39;t be found you&amp;#39;ll get a message back stating that.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;    MD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    arguments &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      required(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:building_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;filled&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;ID of the Building that we are searching in&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;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&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      optional(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:status&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;maybe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:string&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;description&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Comma-separated list of statuses - valid statuses are open, closed, left_ajar, denied; any other statuses will be ignored&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;**&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;args&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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;em&gt;(it&amp;#39;s as important as describing the input&lt;/em&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&amp;#39;s not allowed &lt;em&gt;(e.g. additional enum values besides the
ones listed)&lt;/em&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&amp;#39;d tell it that I expect &lt;em&gt;&amp;quot;building&amp;quot;&lt;/em&gt; as an enum value but it
would pass me &lt;em&gt;&amp;quot;Buildings&amp;quot;&lt;/em&gt;. Or I&amp;#39;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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;presence&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strip&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;singularize&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;underscore&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&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&amp;#39;t do too much damage if it goes haywire. I intentionally skipped that
during the hackathon as it wasn&amp;#39;t important.&lt;/p&gt;
&lt;p&gt;This project was a technical success, but I&amp;#39;m not sure if it&amp;#39;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&amp;#39; pain points
and how to make the overall experience better.&lt;/p&gt;
&lt;p&gt;I&amp;#39;m not sure how to strike a good balance between automating laborious tasks and
surfacing common problems. That&amp;#39;s also something I&amp;#39;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...</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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 504; --attachment-height: 942; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADwAQCdASoJABAABABoJaQAD4fwaOoUwgAA%2Fvd2Wuu%2FGS6yLFkdZAmaU7uJtPVlewD6RkzKgmKJkAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #161616;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-12-55-40.jpg/504w.jpg" width="504" height="942"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-12-55-40.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Him: So you are dabbling in React?&lt;/p&gt;
&lt;p&gt;Me: No, this is just Rails.&lt;/p&gt;
&lt;p&gt;Him: Which libraries did you use?&lt;/p&gt;
&lt;p&gt;Me: None, this is as vanilla as it gets.&lt;/p&gt;
&lt;p&gt;Him: What!?&lt;/p&gt;
&lt;h3 id="backstory"&gt;Backstory&lt;/h3&gt;
&lt;p&gt;For the past 12 months, each month I built an app within a strict time-box of 12
hours.&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;/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;/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;/p&gt;
&lt;h3 id="simpliki"&gt;Simpliki&lt;/h3&gt;
&lt;p&gt;Simpliki is the 12th app I’ve built.&lt;/p&gt;
&lt;p&gt;It’s an app for practicing diaphragmatic breathing.&lt;/p&gt;
&lt;p&gt;I got the idea for it from my girlfriend. She uses a similar app in her
mindfulness practice.&lt;/p&gt;
&lt;p&gt;The name is a combination of “simple” and the Japanese word “iki”, which means
“breath”.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1264; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAcABABoJaQAA3AA%2FvED4EINL3AAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f1f1f1;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.webp 1264w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese" loading="lazy" decoding="async" class="attachment__image" width="1264" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/image.png/1264w.png 1264w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Google Translate result for translating &amp;quot;breath&amp;quot; to Japanese&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;h3 id="starting-from-the-center"&gt;Starting from the center&lt;/h3&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;/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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 400; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAQABABoJaQAA3AA%2FvJI3wLBLtnAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's center" loading="lazy" decoding="async" class="attachment__image" width="1620" height="400" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_1.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s center&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;But I also need a way to get to the breathing exercise - an index screen or
something similar.&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAUABABoJaQAA3AA%2FvJSZ8ZfGwAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfbfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's two centers" loading="lazy" decoding="async" class="attachment__image" width="1620" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_2.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s two centers&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;My girlfriend likes to practice specific exercises so I added an “Explore”
button that leads to an index page with all exercises.&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;/p&gt;
&lt;p&gt;I learned this from &lt;a href="https://en.wikipedia.org/wiki/Christopher_Alexander"&gt;Christopher Alexander&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 650; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoQAAYABABoJaQAA3AA%2FvIP3svEFHil70gAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f8f8f8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's two centers and the features between them" loading="lazy" decoding="async" class="attachment__image" width="1620" height="650" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_center_3.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s two centers and the features between them&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Now that I have an idea what I&amp;#39;m building I&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 850; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAACwAQCdASoQAAgABABoJaQAAxZgveyIAP71HZZP2GNUKye5d9m7oWicnsAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ededed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The breadboard" loading="lazy" decoding="async" class="attachment__image" width="1620" height="850" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_breadboard.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The breadboard&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This also helps me figure out the domain language - what models I have and what
their attributes and methods are called.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 470; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAACwAQCdASoQAAUABABoJaQAAudMszkAAP72EMDXXAAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f9f9f9;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The domain" loading="lazy" decoding="async" class="attachment__image" width="1620" height="470" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_domain.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The domain&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="design"&gt;Design&lt;/h3&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&amp;#39;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;div class="attachment-gallery attachment-gallery--2"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 2796; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAADQAQCdASoHABAABABoJaQAAxf5dBiwWAD%2B8SMRaGBCq4aGfk2To3%2BCi5kb0M7qMXbNZmgwnWA9%2BdAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1c1c1c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.webp 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Endel" loading="lazy" decoding="async" class="attachment__image" width="1290" height="2796" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_endel.png/1290w.png 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Endel&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 2796; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%207%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAAAwAgCdASoHABAABABoJbACdLoAAslGKCMwAAD%2B06Cg8WYaEfophaMLcEUgPbrgpj%2F4tLM9aFi3ocm5pB7KBqQwAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f5ba45;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.webp 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="Headspace" loading="lazy" decoding="async" class="attachment__image" width="1290" height="2796" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_headspace.png/1290w.png 1290w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Headspace&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;p&gt;So I decided to combine the two for Simpliki. I&amp;#39;d use Endel&amp;#39;s color-scheme and
the animated organic shapes from Headspace.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 496; --attachment-height: 941; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%208%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADwAQCdASoIABAABABoJaQAAtzyDq%2B5SAAA%2FvaKubGVzOAzoXB297vtgcDwXa0kmO5A12P1yPK1ULrBDxvTpM%2B4%2FleAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #181818;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.webp 496w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's UI" loading="lazy" decoding="async" class="attachment__image" width="496" height="941" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423095110.png/496w.png 496w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s UI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h3 id="starting-a-new-project"&gt;Starting a new project&lt;/h3&gt;
&lt;p&gt;I decided to use Tailwind because I like working with it. And I chose SQLite as
the database because it&amp;#39;s the simplest one to use out of the presets. I honestly
don&amp;#39;t even need a database for this app, but it&amp;#39;s easier to use one than not.&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; simpliki&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; --css=tailwind&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then in that project I generated my models and a controller&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; model&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; name:string&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; model&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise::Step&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; exercise:belongs_to&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; action:string&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; duration:integer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; position:integer&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; g&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; scaffold_controller&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Exercise&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I removed everything that I didn&amp;#39;t need, added a &amp;quot;home&amp;quot; action to the
controller, a view for that action, and pointed the root route to that action.&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;/p&gt;
&lt;p&gt;Over the past 5 years I&amp;#39;ve completely changed my mind about fixtures. I used to
think that they are finicky and convoluted compared to factories. But I&amp;#39;ve come
to like them more after I&amp;#39;ve seen the downsides of factories over the years -
they are extremely slow, it&amp;#39;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;/p&gt;
&lt;p&gt;My favorite advantage of fixtures is:&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; db:fixtures:load&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This will take all my fixtures and put them in my current &lt;em&gt;(development or
production)&lt;/em&gt; database.&lt;/p&gt;
&lt;p&gt;Now I don&amp;#39;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&amp;#39;s in my tests - no more trying to figure out what went
wrong, I can just open the same Exercise and see.&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 &amp;quot;Simpl息&amp;quot; on each page, added a plain HTML
&amp;quot;Practice&amp;quot; button on the exercise show page, and created a very simple index
page that shows a list of exercises.&lt;/p&gt;
&lt;h3 id="the-ruby-tag"&gt;The ruby tag&lt;/h3&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 752; --attachment-height: 388; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAADQAQCdASoQAAgABABoJaQAAud5hM6MAAD%2B9KEaMMsqlDpXFZmHPkdpI4a%2FGxZCf0GcAGTrLL6PlCmp%2F025pd1AAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #252525;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.webp 752w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Simpliki's logo" loading="lazy" decoding="async" class="attachment__image" width="752" height="388" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423133538.png/752w.png 752w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Simpliki&amp;#39;s logo&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;s name comes from.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;logo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;font-serif&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;mt-4&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Simpl&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;ruby&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  息&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;rt&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text-gray-500&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;iki&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;rt&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;ruby&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It took about a minute to add and style the &amp;quot;iki&amp;quot; - plain HTML is amazing.&lt;/p&gt;
&lt;h3 id="animating-svgs"&gt;Animating SVGs&lt;/h3&gt;
&lt;p&gt;Next I wanted to animate the play button. I&amp;#39;ve done similar things before so I
was quite confident that I could do it quickly.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 596; --attachment-height: 546; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2015%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmwAAABXRUJQVlA4IGAAAAAQAgCdASoQAA8ABABoJaQAAsQk7n71%2FZ0AAP724OeoYHZnaZfHC71Q7VjLSEyjBui%2B20VNPV0tTxDhQsQCm0N7Sf5CgPxWG5EULGQEcY7qvc7SkdJIRWTGvBvHRjuTgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #676767;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-14-32-42-1.jpg/596w.jpg" width="596" height="546"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-14-32-42-1.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the circle animation&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1620; --attachment-height: 500; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAACQAQCdASoQAAUABABoJaQAAuP3fIAA%2FvTRZFX61rCiAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f8f8f8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/800w.webp 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.webp 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Basic SVG circle with 16 points and a Bezier curve" loading="lazy" decoding="async" class="attachment__image" width="1620" height="500" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/800w.png 800w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1200w.png 1200w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1600w.png 1600w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/simpliki_basic_circle.png/1620w.png 1620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Basic SVG circle with 16 points and a Bezier curve&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then, every 10ms I change the position of each point slightly and adjust their
Bezier curve points to animate the circle.&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;/p&gt;
&lt;p&gt;That&amp;#39;s an old computer science trick I learned in college. It sounds
complicated, but it&amp;#39;s quite straightforward.&lt;/p&gt;
&lt;p&gt;There is just one thing you have to know - if you plot &lt;em&gt;x = cos(a)&lt;/em&gt; and &lt;em&gt;y =
sin(a)&lt;/em&gt; where &lt;em&gt;a&lt;/em&gt; is any value between 0 and 2&lt;em&gt;PI&lt;/em&gt; you&amp;#39;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 class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 16&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; generatePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;++&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Calculate at what angle the point is supposed to be&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; angle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;point&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PI&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Convert the angle to coordinates&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; y&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.push&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; y])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; circlePoints&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 620; --attachment-height: 550; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2014%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmYAAABXRUJQVlA4IFoAAAAwAgCdASoQAA4ABABoJaQAD4ounrKC10XwAAD%2B94JSFOdaL%2BNU6o5QRXt8JR9MlpP1uQI7Bt90a4yQAOrQm2WItngKT0ZxvJ0DWUy1xj3vzV%2BFVJEAGEoAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #626262;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.webp 620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The most basic circle" loading="lazy" decoding="async" class="attachment__image" width="620" height="550" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150750.png/620w.png 620w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The most basic circle&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 16&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // determines how quickly the waves move&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_AMPLITUDE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // determines how big the waves are&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; generatePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;; i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;++&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Calculate at what angle the point is supposed to be&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; angle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;point&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; NUM_POINTS&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;PI&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Generate a new radius&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // the values were completely arbitrarily chosen&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // you could also just use sin(time) + cos(time)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // this just looks nice to me&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; rippleY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; angle)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; rippleX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(time &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_FREQ&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.7&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; angle &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1.3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ripple&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (rippleY &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.6&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; rippleX &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.4&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RIPPLE_AMPLITUDE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Convert the angle to coordinates&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (ripple &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cos&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; y&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; RADIUS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (ripple &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Math&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.sin&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(angle))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    circlePoints&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.push&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([x&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; y])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; circlePoints&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 550; --attachment-height: 544; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRnIAAABXRUJQVlA4IGYAAABQAgCdASoQABAABABoJaQAD4fvqlHIRB5NzAAA%2FveB1xNPFx%2Bzfa7oI%2FD3SRkqt1KCaeMzI9YL1u8iZodH%2BIdiuhp6yNUsfc9lo64cmjEj7dNrNWQ7fUebShwj4ErDATsH3RVAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6f6f6f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.webp 550w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Circle with ripples" loading="lazy" decoding="async" class="attachment__image" width="550" height="544" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423150838.png/550w.png 550w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Circle with ripples&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 503; --attachment-height: 519; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRngAAABXRUJQVlA4IGwAAACQAgCdASoQABAABABoJaQAFEB7LYvNVvZ%2BfVNfoAD%2B9uL%2FSzIJ66%2F3o65Uj4LScVo8LIGbWWsm794J9ZR53%2ByCSAbzlH7%2F6WSnACLw8ZSpaPiMT%2FeHXT6XW986OAuh8brfPCgsta%2BfA2YAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #808080;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/480w.webp 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.webp 503w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Circle with ripples and fixed Bezier curves" loading="lazy" decoding="async" class="attachment__image" width="503" height="519" src="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png" srcset="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/480w.png 480w, https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/pasted-image-20250423151922.png/503w.png 503w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Circle with ripples and fixed Bezier curves&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The end effect is subtle, but it makes the circle look friendlier.&lt;/p&gt;
&lt;h3 id="view-transitions"&gt;View transitions&lt;/h3&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;/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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;meta&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;view-transition&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;same-origin&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&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;em&gt;view-transition-name&lt;/em&gt; CSS property with a unique name.&lt;/p&gt;
&lt;pre class="highlight" data-language="css"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;#logo&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  view-transition-name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; logo&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;After that the browser animates the transitions on its own.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1510; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAACwAQCdASoNABAABABoJaQAAudf%2FWoAAP73dNXZZldjc8%2BhzjGGNOpleq%2Bng8OOFxQx3YjjpfT9i8KMxIy%2FDUg2gAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #202020;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-35-32.jpg/850w.jpg" width="850" height="1510"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-35-32.mp4" type="video/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;
&lt;p&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;pre class="highlight" data-language="css"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;@keyframes&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; slide-to-top-from-out-of-view {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  0% {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; translateY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;(100&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;vh&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    opacity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  100% {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; translateY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;(0)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    opacity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;#exercises&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  view-transition-name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; exercises&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:not&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data-same-page-visit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data-turbo-visit-direction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;forward&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]::view-transition-new(exercises) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ease-out slide-to-top-from-out-of-view both&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This tells the browser to apply a &lt;em&gt;slide-to-top-from-out-of-view&lt;/em&gt; animation for
any new exercises list that appear.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1510; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAADQAQCdASoNABAABABoJaQAAudN6%2BIlgAD%2B93P%2B4VN6b5frvlDM%2BdKJMcttCCYcDhlH6P0%2BMFyIZcPP1btpjTW6BiQXKEux0AAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #212121;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-23-44.jpg/850w.jpg" width="850" height="1510"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-15-23-44.mp4" type="video/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;
&lt;p&gt;There is one caveat, View Transitions aren&amp;#39;t supported on Firefox. This only
works on Chromium and WebKit based browsers. As one of the 2% of global Firefox
users I don&amp;#39;t mind, the app still works without the transitions.&lt;/p&gt;
&lt;h3 id="the-breathing-animation"&gt;The breathing animation&lt;/h3&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;/p&gt;
&lt;p&gt;The circle should grow when you have to breathe in, and shrink when you have to
breathe out.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 658; --attachment-height: 710; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmIAAABXRUJQVlA4IFYAAAAwAgCdASoPABAABABoJaQAAsRD0TEeugmUAAD%2B94JHAhJFmKyQPGcliVICmdzORmdedJFZi6%2ByDcdhVa9Jx4%2BqIueNmEQI5WlKrn3YDJPzDXeqAIAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #494949;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-16-31-36.jpg/658w.jpg" width="658" height="710"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-16-31-36.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;Demo of the breathing animation&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;To animate this I need to create three circles - a maximum indicator, a minimum
indicator and a progress indicator.&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;/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;/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;em&gt;use&lt;/em&gt; tag. You can even change the fill and stroke properties on the &lt;em&gt;use&lt;/em&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Defines a symbol &amp;quot;foo&amp;quot; as a circle --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;circle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; cx&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; cy&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;50&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Renders a red circle with a black stroke --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; href&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fill&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;red&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stroke&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;black&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stroke-width&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;2&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- Renders a blue circle --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; xmlns&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://www.w3.org/2000/svg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; viewBox&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;0 0 100 100&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; href&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fill&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;blue&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;use&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;svg&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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&amp;#39;ve created a helper to make
things more
readable.&lt;/a&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;circle_id &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; dom_id(@exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :circle&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders an animated circle as a symbol with the id circle_id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;symbol:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;hidden&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the minimum indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-30 w-full select-none&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; stroke:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;black&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; stroke_width:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;exercise-circle-min&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;minCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the progress indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-20 w-full select-none&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;progressCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Renders the maximum indicator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;animated_circle(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;use:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; circle_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; class:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;absolute inset-0 z-10 w-full select-none opacity-10&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;exercise-circle-max&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;exercise_target:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;maxCircle&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; })&lt;/span&gt;&lt;/span&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;/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;/p&gt;
&lt;p&gt;Long story short, don&amp;#39;t use fill forwards and instead manually set your desired
state with the &lt;em&gt;onfinish&lt;/em&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;/p&gt;
&lt;p&gt;After I figured that out, the rest was straightforward&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;async&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; inhale&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; element) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Promise&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(resolve &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.animate&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      easing&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;linear&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    animation&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;onfinish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; () &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;style&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.transform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      resolve&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;async&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; exhale&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; element) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Promise&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(resolve &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; animation&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.animate&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(1.0)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      { transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      easing&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;linear&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    animation&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;onfinish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; () &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;progressCircleTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;style&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.transform &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `scale(0.25)`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      resolve&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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;em&gt;steps&lt;/em&gt; value of type Array and serialize
the steps as JSON on the HTML element.&lt;/p&gt;
&lt;h3 id="sound"&gt;Sound&lt;/h3&gt;
&lt;p&gt;With the animation done I wanted to also add an audio cue that you should
breathe in or out.&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;/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&amp;#39;s bells.&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&amp;#39;t run out of time.&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 &amp;quot;a nice and relaxing sound
that inspires breathing in a mindfulness app I&amp;#39;m working on, kind of like Endel
or Headspace&amp;quot;. &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;/p&gt;
&lt;p&gt;But it didn&amp;#39;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;em&gt;OFF&lt;/em&gt; silent.&lt;/p&gt;
&lt;h3 id="scroll-snapping"&gt;Scroll snapping&lt;/h3&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;/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 class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; action_text:install&lt;/span&gt;&lt;/span&gt;&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&amp;#39;t
accidentally scroll away.&lt;/p&gt;
&lt;p&gt;Once again HTML, or rather CSS, saves the day - this time with scroll-snap.&lt;/p&gt;
&lt;p&gt;I just added the &lt;a href="https://tailwindcss.com/docs/scroll-snap-type"&gt;&lt;em&gt;snap-y&lt;/em&gt; and
&lt;em&gt;snap-mandatory&lt;/em&gt;&lt;/a&gt; Tailwind
classes to the container, and
&lt;a href="https://tailwindcss.com/docs/scroll-snap-align"&gt;&lt;em&gt;snap-start&lt;/em&gt;&lt;/a&gt; to the container
of the exercise circle - that&amp;#39;s it. Now the browser snaps to the top when you
scroll near the top of the page.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAAAwAgCdASoNABAABABoJaQAAu193HWLoeFMWAD%2B94DK1NeZsxFe14ki69lTHdjqNYcLzeYJ3x8Fdx%2FvH%2BSSgr6d9lgB6ub9GxEXjDkYAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f1f1f;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-17-30-35.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-17-30-35.mp4" type="video/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;h3 id="full-text-search"&gt;Full text search&lt;/h3&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;/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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AddFullTextSearchToExercises&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Migration&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;8.0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; change&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    create_virtual_table &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:exercise_full_text_search_vectors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :fts5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; %w[name description]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# frozen_string_literal: true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Exercise&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Searchable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    extend&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Concern&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    DEFAULT_LIMIT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 25&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; FullTextSearchVector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;table_name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    included &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      has_one &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; foreign_key:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :rowid&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; dependent:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :destroy&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      after_save &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:create_or_update_full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    class_methods &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;query&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; all &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; query&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; MATCH ?&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; escape_fts_match_value(query))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;joins&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;order&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;bm25(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;distinct&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; escape_fts_match_value&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;input&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        input&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;gsub&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;&amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;split&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/\s+/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;word&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; %Q(&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{word}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create_or_update_all_full_text_search_vectors&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:create_or_update_full_text_search_vector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create_or_update_full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      params &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        description:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; description&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_plain_text&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      sql &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; full_text_search_vector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Base&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sanitize_sql_array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;            &amp;quot;UPDATE &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; SET name = :name, description = :description WHERE rowid = :id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;            params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActiveRecord&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Base&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sanitize_sql_array&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;            &amp;quot;INSERT INTO &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;VECTOR_TABLE_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; (rowid, name, description) VALUES (:id, :name, :description)&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;            params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;execute&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(sql)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;With that I can do a full-text search across all exercises&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exercise&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;search&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;box&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="pagination"&gt;Pagination&lt;/h3&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;/p&gt;
&lt;p&gt;They have a &amp;quot;pagination&amp;quot; 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;/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;/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&amp;#39;s document. My first stab worked quite well.&lt;/p&gt;
&lt;pre class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  async #&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetchNextPageDocument&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.href&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      headers&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;        &amp;quot;Accept&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;response&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.ok) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; DOMParser&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.parseFromString&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.text&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;text/html&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.error&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Failed to fetch next page:&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.status)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&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 class="highlight" data-language="js"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; await&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.#fetchNextPageDocument&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; newList&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`[data-&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;-target=&amp;quot;list&amp;quot;]`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Array&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.from&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;newList&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.children)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.forEach&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(child &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;listTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.appendChild&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;child&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cloneNode&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; newNextPageLink&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`[data-&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;-target=&amp;quot;nextPageLink&amp;quot;]`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (newNextPageLink) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.replaceWith&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;newNextPageLink&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.cloneNode&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;else&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nextPageLinkTarget&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.remove&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjYAAABXRUJQVlA4ICoAAAAwAQCdASoNABAABABoJaQAA3AA%2FvI63WV8raZ3MQfIrHmHUIaIiIx%2F4AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #0c0c0c;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-34-33.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-34-33.mp4" type="video/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;h3 id="auth-create-update--delete"&gt;Auth, create, update &amp;amp; delete&lt;/h3&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;/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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 850; --attachment-height: 1512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAAAwAQCdASoNABAABABoJaQAA3AA%2FvM7MHZzh1j7F9fqwjmoAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #050505;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-40-17.jpg/850w.jpg" width="850" height="1512"&gt; &lt;source src="https://media.stanko.io/originals/articles/building-simpl-77CAzym51p5a/screencast-from-2025-04-23-18-40-17.mp4" type="video/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;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;I wanted to share how I built Simpliki because everyone I show it to seems to
think that it&amp;#39;s some SPA made with the latest technology trend when in reality
it&amp;#39;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;/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;/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...</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;p&gt;My first job was at a digital agency. In addition to app development we also
hosted the apps that we&amp;#39;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&amp;#39;t have to manage dozens of machines at different providers. All in all,
this was a good setup.&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;This incident left a sour taste in everybody&amp;#39;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&amp;#39;t have to monitor the hardware and backups were built-in.&lt;/p&gt;
&lt;p&gt;Since that Friday, for more than a decade now, I&amp;#39;ve used AWS at every job I had.&lt;/p&gt;
&lt;p&gt;I&amp;#39;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&amp;#39;t worth it for most people.&lt;/p&gt;
&lt;h3 id="aws-makes-things-easy-not-simple"&gt;AWS makes things easy, not simple&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;d host it yourself.&lt;/p&gt;
&lt;p&gt;In other words, the complexity is the same, but the setup is easier.&lt;/p&gt;
&lt;p&gt;In fact, it&amp;#39;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&amp;#39;t do. And on top of that it also introduces new concepts that you&amp;#39;ll have to
learn and manage - Floating IPs, Load Balancers, CPU credits, Network credits,
IOPS credits, EBS credits, VPCs, Security Groups, IAM roles...&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Setting up a VPS is extremely easy, but if you don&amp;#39;t know Linux, networking and
some AWS gotchas, but managing and monitoring the instance is still complex.&lt;/p&gt;
&lt;p&gt;Think of it as sending an email. It&amp;#39;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;/p&gt;
&lt;p&gt;I hope that some of the acronyms and concepts of the complexity of email servers
are foreign to you because that&amp;#39;s my whole point.&lt;/p&gt;
&lt;p&gt;By buying email from Gmail you are giving Google money so that you don&amp;#39;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&amp;#39;s
up to you to deal with the complexity of running that server.&lt;/p&gt;
&lt;h3 id="take-everything-with-a-grain-of-salt"&gt;Take everything with a grain of salt&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Figuring out what you are &lt;em&gt;really&lt;/em&gt; buying requires you to read the product&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;t reliably know if a client has
disconnected&lt;/a&gt;.&lt;/p&gt;
&lt;p&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&amp;#39;s performance negating the
benefit of having the
replica&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There are many more examples.&lt;/p&gt;
&lt;p&gt;But the gist of the story is that you have to carefully navigate AWS&amp;#39;
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&amp;#39;t
100% sure what you&amp;#39;ve purchased or how much it will cost you in the end.&lt;/p&gt;
&lt;p&gt;Some might say that this is a &amp;quot;skill issue&amp;quot;, or just &amp;quot;RTFM&amp;quot;. But I think that
people who say this are either stuck in an inferiority complex or are just
coping.&lt;/p&gt;
&lt;p&gt;Imagine if any other business sold stuff like AWS does. Let&amp;#39;s take a restaurant
as an example.&lt;/p&gt;
&lt;p&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&amp;#39;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 &amp;quot;limitation list&amp;quot;. Then he brings you the
limitation list which states that the pizza you ordered is served per slice.&lt;/p&gt;
&lt;p&gt;You&amp;#39;d probably never go to that place ever again.&lt;/p&gt;
&lt;h3 id="sometimes-it-can-feel-like-racketeering"&gt;Sometimes it can feel like racketeering&lt;/h3&gt;
&lt;p&gt;After using AWS for a while I&amp;#39;ve noticed that it isn&amp;#39;t as elastic as it claims
to be, and somehow that feels intentional.&lt;/p&gt;
&lt;p&gt;Let&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But we soon learned that, while we did get 10x the CPU, we got only 1/5 of the
bandwidth.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;em&gt;That&amp;#39;s a 20x increase in price for 10x the compute.&lt;/em&gt;&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;Nearly all AWS services give you a predefined list of instance types to choose
from - this is not just an ElastiCache issue.&lt;/p&gt;
&lt;p&gt;I understand why smaller hosting providers make you choose from predefined
instance types - they simply don&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;If you have 50k clients you&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The pizza would have to be out of this world for me to come back to that
restaurant.&lt;/p&gt;
&lt;p&gt;When you run into such pricing practices AWS doesn&amp;#39;t feel like a partner helping
you build stuff anymore; it feels like an adversary trying to squeeze every
penny out of you.&lt;/p&gt;
&lt;h3 id="serfs-and-sovereigns"&gt;Serfs and sovereigns&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&gt;When it comes to great and novel services, the first thing that comes to my mind
is S3.&lt;/p&gt;
&lt;p&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&amp;#39;s&lt;/a&gt;,
&lt;a href="https://www.ovhcloud.com/en/public-cloud/object-storage/"&gt;OVH&amp;#39;s&lt;/a&gt; and
&lt;a href="https://www.scaleway.com/en/object-storage/"&gt;Scaleway&amp;#39;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&amp;#39;t
always the case.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;When it comes to S3 - 10 years ago we were basically serfs, today we are
sovereign and I don&amp;#39;t miss the old days one bit.&lt;/p&gt;
&lt;p&gt;There are other great AWS services - like DynamoDB - but I avoid them in favor
of open-source alternatives just because I don&amp;#39;t want to find myself locked in
and having to accept any term change to avoid spending days switching to
something else.&lt;/p&gt;
&lt;h3 id="competence"&gt;Competence&lt;/h3&gt;
&lt;p&gt;The incident that happened at my first job wasn&amp;#39;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&amp;#39;t know how to use them. It was our incompetence that caused the incident.&lt;/p&gt;
&lt;p&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&amp;#39;t. All it did was
mask that incompetence by making things easy to set up, but our old problems all
eventually reoccurred.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;And that&amp;#39;s the take-away here - AWS, or any other managed service provider,
won&amp;#39;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&amp;#39;s
acceptable for you. They will give you hardware, but you have to know what&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&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;p&gt;A few weeks ago I was woken up with the following message &amp;quot;Hey, devices are
randomly disconnecting from WebSockets can you come online and help?&amp;quot;.&lt;/p&gt;
&lt;p&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&amp;#39;s functioning correctly and then drill down on
any components that aren&amp;#39;t.&lt;/p&gt;
&lt;h3 id="step-one-identify-which-components-could-be-at-fault"&gt;Step one, identify which components could be at fault.&lt;/h3&gt;
&lt;p&gt;We use Rails&amp;#39; ActionCable for WebSockets. I&amp;#39;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&amp;#39;d
like to read more about it.&lt;/p&gt;
&lt;p&gt;ActionCable, except for the Ruby process itself, depends on Redis (in my case)
and on the database(s) that the app uses.&lt;/p&gt;
&lt;h3 id="step-two-prioritize"&gt;Step two, prioritize.&lt;/h3&gt;
&lt;p&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&amp;#39;s worker pool to lag which then caused missed heartbeats.&lt;/p&gt;
&lt;p&gt;This means that the Ruby process is the most likely culprit, then the database,
and finally Redis.&lt;/p&gt;
&lt;h3 id="step-three-check-each-components"&gt;Step three, check each components.&lt;/h3&gt;
&lt;p&gt;I checked Grafana to see what&amp;#39;s going on with the app containers and saw that
CPU was elevated but wasn&amp;#39;t exceeding 80%, while memory didn&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Next I checked the database. CPU and memory were at average levels, there were
no slow queries. Everything was fine.&lt;/p&gt;
&lt;p&gt;Lastly, I checked Redis. To my surprise, here too, everything was fine. The CPU
never exceeded 10% and memory was at 5%.&lt;/p&gt;
&lt;p&gt;What was going on?&lt;/p&gt;
&lt;h3 id="step-four-stabilize-the-app"&gt;Step four, stabilize the app.&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;h3 id="step-five-find-the-root-cause"&gt;Step five, find the root cause.&lt;/h3&gt;
&lt;p&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&amp;#39;s going on - how many clients there are, what
the latency was like, how many messages were being sent...&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I&amp;#39;m not one to reinvent the wheel, so I checked if some tool for monitoring
ActionCable already exists. I found out that Sentry&amp;#39;s and AppSignal&amp;#39;s APM tools
monitor ActionCable&amp;#39;s action execution times.&lt;/p&gt;
&lt;h3 id="action-execution-time"&gt;Action Execution Time&lt;/h3&gt;
&lt;p&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&amp;#39;s a nice way to quickly see if everything
is alright with ActionCable.&lt;/p&gt;
&lt;p&gt;I quickly exposed that metric to Elastic APM using the following code.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/elastic_apm.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;perform_action.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  transaction &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ElasticAPM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;start_transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:channel_class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;start&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000.0&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  ElasticAPM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;end_transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:exception&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That worked!&lt;/p&gt;
&lt;h3 id="pubsub-latency"&gt;PubSub latency&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 793; --attachment-height: 255; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAACQAQCdASoQAAUABABoJZwAApz%2BdxAA%2FvB8mpyMqPZf8CwBTwMS6wBmudfoAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #ece5e8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.webp 793w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that shows what PubSub latency measures" loading="lazy" decoding="async" class="attachment__image" width="793" height="255" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pubsub_latency.png/793w.png 793w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Diagram that shows what PubSub latency measures&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;measurement_payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To receive the message and calculate the latency I can do this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_from(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  sent_at &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:sent_at&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; sent_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub Latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But where can I put this code and how do I expose the latency?&lt;/p&gt;
&lt;p&gt;After some experimentation I decided to create a stream from every open channel
in ActionCable. This sounds pretty bad, and I&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Create the mutex and a counter&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;mutex &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Defines a proc that prints the value of the counter&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# and increments it by 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;work &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  10&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;times&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;synchronize&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      sleep&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      puts&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Spawns 50 threads that all call the proc we just defined&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# and then waits for all the threads to finish&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;50&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;times&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Thread&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { work&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_a&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Prints the final count after all the threads are done&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;puts&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Final count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you now run this code from IRB and look at the output you&amp;#39;d see that the
count always goes up by one and that the final count is 500&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 487&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 488&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 489&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 490&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 491&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 492&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 494&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 497&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 498&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 499&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Final count: 500&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But if you remove the mutex, first you&amp;#39;d notice that the code runs much faster,
second thing you&amp;#39;d notice is that the output is completely random, sometimes
repeats, and change each time you run this&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 487&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 488&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 489&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 490&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 491&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 492&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 493&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 495&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 496&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;count: 494&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Final count: 497&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;That&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;t block and instead returns true if it acquired the lock
or false if it didn&amp;#39;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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; sent_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub Latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_from(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metrics&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  collect(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To make things prettier I put all code related to metrics collection into a
module&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MetricsCollector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    STREAM_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;internal.action_cable.metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    COLLECTION_TRESHOLD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 60&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    cattr_accessor &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:last_collected_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; STREAM_NAME&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_payload&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_already_collected?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          collect_measurement(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_already_collected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;after?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ago&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; COLLECTION_TRESHOLD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{pub_sub_latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Then I changed my cron job to run&lt;/p&gt;
&lt;pre class="highlight" data-language="shell"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bin/rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; runner&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ActionCable::MetricsCollector.measure&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And added the following to &lt;em&gt;app/channels/application_cable/channel.rb&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    after_subscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      stream_from(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        ::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MetricsCollector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        proc &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          payload &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decode(json)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;          ::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;MetricsCollector&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        rescue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;error&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Failed to collect metrics: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{e}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;How do I expose this metric to Elastic APM?&lt;/p&gt;
&lt;p&gt;After some thought I decided I wouldn&amp;#39;t. I could hack something together, but
I&amp;#39;m shoehorning this metric in and later metrics I flat out don&amp;#39;t know how to
expose in Elastic AMP. So I decided not to bother with this and figure it out
later.&lt;/p&gt;
&lt;h3 id="number-of-connections"&gt;Number of connections&lt;/h3&gt;
&lt;p&gt;Since I&amp;#39;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;/p&gt;
&lt;p&gt;Luckily, with the module that I&amp;#39;ve added to measure the PubSub latency it&amp;#39;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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PubSub latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{pub_sub_latency}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Connection count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{connection_count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Without that module I&amp;#39;d probably setup a periodic task like so&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    periodically &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;every:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;minute&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Connection count: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{connection_count}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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&amp;#39;s memory.&lt;/p&gt;
&lt;h3 id="client-server-latency"&gt;Client-Server Latency&lt;/h3&gt;
&lt;p&gt;Since I&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1365; --attachment-height: 400; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAADQAQCdASoQAAUABABoJaQAAp3InkS1gAD%2B8HxCO6UZvG17%2BSRY7P4QoW6AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #e8e6e8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.webp 1365w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that shows what Client-Server latency measures" loading="lazy" decoding="async" class="attachment__image" width="1365" height="400" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/client_server_latency.png/1365w.png 1365w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Diagram that shows what Client-Server latency measures&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;This metric isn&amp;#39;t useful for regular web apps as it&amp;#39;s non actionable - you can&amp;#39;t
fix your client&amp;#39;s Internet. But it&amp;#39;s useful for apps, like mine, where you have
control over clients and want to have them connected 24/7.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Stock Rails 8 doesn&amp;#39;t have this notificaiton&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;client_latency.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Client latency: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Now that I have all these metrics I have to expose them somehow.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But what is Prometheus?&lt;/p&gt;
&lt;p&gt;Prometheus is an open-source monitoring and alerting system - it&amp;#39;s a service
that collects metrics, allows you to query them, and to configure alerts when
they exceed a threshold. It&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1420; --attachment-height: 610; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkwAAABXRUJQVlA4IEAAAADwAQCdASoQAAcABABoJZQC7AEQ%2FBDW0gAA%2Fu2qAdDl7F4oQ3mvVyo8%2BwlVwr%2FuEwPWJdGyd9t3EG3QUX2CWgAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #dedede;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.webp 1420w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Diagram that explains the difference between Prometheus and other APM services" loading="lazy" decoding="async" class="attachment__image" width="1420" height="610" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/prometheus.png/1420w.png 1420w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;Usually, you&amp;#39;d expose a /metrics endpoint that renders all the metrics the
application has collected in a format that Prometheus can parse.&lt;/p&gt;
&lt;p&gt;I wasn&amp;#39;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&amp;#39;s dev blog and it mentioned a gem called
Yabeda&lt;/a&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;m going to expose.
Yabeda supports a handful of types of metrics - counters, gauges, and
histograms - so let me explain those first.&lt;/p&gt;
&lt;p&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&amp;#39;t a counter
since the number goes up when more clients connect and goes down when clients
disconnect.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Finally, a histogram represents a distribution of values. It tells you how many
measurements you&amp;#39;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&amp;#39;d use a histogram to
represent time measurements like response times.&lt;/p&gt;
&lt;p&gt;Defining these metrics is fairly straight forward&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/yabeda.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  group &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel via the Internet to a client&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:pubsub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel through the PubSub service&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_execution_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes to perform an invoked action&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    gauge &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; comment:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Number of open WebSocket connections&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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&amp;#39;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&amp;#39;ll explain those later. The second is the measurement itself.
Without the second argument, Yabeda will execute the block, measure it&amp;#39;s
execution time, and report that.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.123&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# or&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  sleep&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.123&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # do some work&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I can now update all ActiveSupport Notification subscriptions to report to
Yabeda, and I can move them all to Yabeda&amp;#39;s initializer.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/initializers/yabeda.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;configure&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  group &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:client_server_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel via the Internet to a client&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:pub_sub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes for a message to travel through the PubSub service&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    histogram &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action_execution_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      comment &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;The time it takes to perform an invoked action&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      unit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      buckets [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0.005&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.01&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.025&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.05&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.25&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2.5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 5&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    gauge &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; comment:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Number of open WebSocket connections&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;perform_action.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:channel_class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;]}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  duration &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; /&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1000.0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # convert ms to seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_execution_duration&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;action:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; action }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; duration) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# I&amp;#39;ve used a tag here&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;client_latency.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  client_server_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:value&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;client_server_latency&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; client_server_latency)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;You might have noticed that I&amp;#39;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&amp;#39;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;/p&gt;
&lt;p&gt;Without tags I&amp;#39;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;/p&gt;
&lt;p&gt;I have to update the metrics collector too&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MetricsCollector&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Mutex&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    STREAM_NAME&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;internal.action_cable.metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    COLLECTION_TRESHOLD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 60&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    cattr_accessor &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:last_collected_at&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; stream_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; STREAM_NAME&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_payload&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          sent_at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        run_unless_already_running &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          next&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; measurement_already_collected?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;          collect_measurement(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; measurement_already_collected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; last_collected_at&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;after?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ago&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collection_trashold&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; COLLECTION_TRESHOLD&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; run_unless_already_running&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        lock_acquired &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;try_lock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      ensure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        MUTEX&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;unlock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; lock_acquired&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; collect_measurement&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;payload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        pub_sub_latency &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;sent_at&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Float&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;INFINITY&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;max&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;pub_sub_latency&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; pub_sub_latency)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;		connection_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connections&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;length&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;		Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; connection_count)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that my metrics are in Yabeda, I have to somehow expose them to Prometheus.
For that I&amp;#39;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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/routes.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;application&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;routes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;draw&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  mount &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Prometheus&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exporter&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; at:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/metrics&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Additionally, when running multiple workers, you probably also want to add
&lt;em&gt;&amp;quot;aggregation: :sum&amp;quot;&lt;/em&gt; to all gauges. Without that option, if a worker fails and
gets restarted, you&amp;#39;ll get very wonky metrics because the gauges will get
counted twice with the old worker&amp;#39;s gauge being stuck at whatever value it was
before the worker crashed.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# HELP action_cable_action_execution_duration_seconds Multiprocess metric&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# TYPE action_cable_action_execution_duration_seconds histogram&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;+Inf&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.005&amp;quot;} 101&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.01&amp;quot;} 559&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.025&amp;quot;} 2044&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.05&amp;quot;} 2174&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.1&amp;quot;} 2181&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.25&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;0.5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;1&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;10&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;2.5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_bucket{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;,le=&amp;quot;5&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_count{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;} 2189&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;action_cable_action_execution_duration_seconds_sum{environment=&amp;quot;development&amp;quot;,action=&amp;quot;ChatChannel#post_message&amp;quot;} 34.04095268249512&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s my action execution duration. It works!&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;h3 id="analyzing-the-metrics"&gt;Analyzing the metrics&lt;/h3&gt;
&lt;p&gt;Now that I had all the metrics somewhere where I could query them I had to make
them easy to analyze. I&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2755; --attachment-height: 1338; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRi4AAABXRUJQVlA4ICIAAACwAQCdASoQAAgABABoJZQAAud%2F2ziAAP7p%2BdzZ3pVrZeAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #293931;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The number of connections to ActionCable plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2755" height="1338" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090243.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The number of connections to ActionCable plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The average is important for scaling as it&amp;#39;s used in &lt;a href="https://en.wikipedia.org/wiki/Little%27s_law"&gt;Little&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2746; --attachment-height: 1453; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAgABABoJaQAA3AA%2FvCIDbiLQuX1YAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #212121;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The action execution latency of all actions plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2746" height="1453" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090532.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The action execution latency of all actions plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;In addition to plotting the overall latency across all actions I&amp;#39;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&amp;#39;s causing a problem.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2687; --attachment-height: 873; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAADwAQCdASoQAAUABABoJZQC7AEUpTsN6eAA%2Fu8%2BVhGvzvnYcp9nAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #293638;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A heatmap that breaks down the action execution latency by action over time" loading="lazy" decoding="async" class="attachment__image" width="2687" height="873" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090930.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;I&amp;#39;ve plotted the client-server latency and PubSub latency the same way as the
overall action execution duration.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2751; --attachment-height: 1460; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAgABABoJaQAA3AA%2FvCcaHHAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1e1d1e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Client-Server latency plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2751" height="1460" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090630.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Client-Server latency plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;-&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2751; --attachment-height: 1468; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvCcdqQcAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1d1d1e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/800w.webp 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="PubSub latency plotted over time" loading="lazy" decoding="async" class="attachment__image" width="2751" height="1468" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/800w.png 800w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1200w.png 1200w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/1600w.png 1600w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127090713.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;PubSub latency plotted over time&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;t have any metrics from it so
I&amp;#39;d have to wait for another one.&lt;/p&gt;
&lt;h3 id="step-six-fixing-the-root-cause"&gt;Step six, fixing the root cause.&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Then, after two weeks, the incident happened again!&lt;/p&gt;
&lt;p&gt;I quickly opened Grafana and saw that the PubSub latency was extremely high.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 767; --attachment-height: 809; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoPABAABABoJaQAA3AA%2FvBwsCJBcknZlKnCAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f2221;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/480w.webp 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.webp 767w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="PubSub latency during the incident" loading="lazy" decoding="async" class="attachment__image" width="767" height="809" src="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png" srcset="https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/480w.png 480w, https://media.stanko.io/variants/articles/monitoring-actioncable-GdeaeHfIU4Yk/pasted-image-20250127092424.png/767w.png 767w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;PubSub latency during the incident&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;(the 95th and 99th percentile are capped at 10s because that&amp;#39;s the largest
bucket I&amp;#39;ve configured)&lt;/em&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I spent the next week investigating ElastiCache Redis and confirmed that it was
the root cause of both incidents. I&amp;#39;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;/p&gt;
&lt;h3 id="looking-back"&gt;Looking back&lt;/h3&gt;
&lt;p&gt;I would never have found and fixed this without the monitoring that I&amp;#39;ve set up.&lt;/p&gt;
&lt;p&gt;In the following month I&amp;#39;ve exposed more metrics like the broadcast and
transmission latency, and the number of confirmed and rejected subscriptions.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;broadcast.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit_subscription_confirmation.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;confirmed_subscription_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; by:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Notifications&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;subscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;transmit_subscription_rejection.action_cable&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; _payload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;transmission_duration&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;measure&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; finish&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; -&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_f&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Yabeda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rejected_subscription_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; by:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I don&amp;#39;t really need those metrics now, but through this incident I&amp;#39;ve learned
that it&amp;#39;s better to have a metric before you need it so I exposed everything I
could expose with ActiveSupport Notifications.&lt;/p&gt;
&lt;p&gt;Additionally, I&amp;#39;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;/p&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;p&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;/p&gt;
&lt;p&gt;The diversity in opinions and aproaches is what makes modern day software
development great. That’s what drives innovation.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But I was wrong back then.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1290; --attachment-height: 781; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAAAQAgCdASoQAAoABABoJaQAD4APth8O97EAAP7YUE%2B0DJ8u%2F8GgfQlInitX3B4Dtk8%2BwmBCPWzSsfodu1434NAAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d4d4d4;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.webp 1290w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Optical illusion that sometimes looks like a rabbit and sometimes like a duck (well, bird)" loading="lazy" decoding="async" class="attachment__image" width="1290" height="781" src="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/software-is-made-by-people-gWZri0iMla1q/img_7410.jpeg/1290w.jpg 1290w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&gt;&lt;em&gt;First off, what are we talking about?&lt;/em&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;&amp;quot;polymorphic
associations&amp;quot;&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AccessLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  belongs_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  belongs_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; polymorphic:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Since grantor is polymorphic we can assign any type of model to it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; AccessLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Device&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Schedule&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; Schedule&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Since the device association isn&amp;#39;t polimorphic we can&amp;#39;t assign anything&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# but a device to it&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; ActiveRecord::AssociationTypeMismatch: Device expected, got Person&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;A polymorphic and a regular association are implemented differently in the
database.&lt;/p&gt;
&lt;p&gt;A regular association is just a column that holds an ID that points to a record
in the other model&amp;#39;s table; usually it also has a foreign key constraint, which
ensures that the assigned ID actually exists in the other table.&lt;/p&gt;
&lt;p&gt;While a polymorphic association uses two columns, one to hold the type of model
it&amp;#39;s for and another to hold the ID of the record it&amp;#39;s for.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;create_table &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;access_logs&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; force:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :cascade&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Regular association - holds the ID of the record from the devices table&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;device_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Polymorphic association&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;string&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # holds the model name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # holds the ID of the record&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;index&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;device_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;index_access_logs_on_device_id&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  t&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;index&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;grantor_type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;grantor_id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;index_access_logs_on_entry&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ensures that every ID in device_id coresponds to an id in the devices table&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_foreign_key &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;access_logs&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;devices&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;&lt;em&gt;Why does it matter which column is first in the index?&lt;/em&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 936; --attachment-height: 945; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoQABAABABoJZwAA3AA%2FvICN5kBFsDL6EAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fafbf7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.webp 936w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Comparison between a book and a naive database index" loading="lazy" decoding="async" class="attachment__image" width="936" height="945" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240319080622.png/936w.png 936w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Comparison between a book and a naive database index&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The problem with this kind of index is that it will grow as the number of
distinct values in the indexed column grows.&lt;/p&gt;
&lt;p&gt;E.g. if you create an index on a column that holds a person&amp;#39;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;/p&gt;
&lt;p&gt;In a dictionary, you&amp;#39;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&amp;#39;d go to page 342 and search for the word
&lt;em&gt;&amp;quot;Ruby&amp;quot;&lt;/em&gt; until you reached page 361.&lt;/p&gt;
&lt;div class="attachment-gallery attachment-gallery--2"&gt; &lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 703; --attachment-height: 483; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAsABABoJaQAA3AA%2FvJKm047yOu5AgA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfa;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.webp 703w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="A multi-level index from a dictionary" loading="lazy" decoding="async" class="attachment__image" width="703" height="483" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104044.png/703w.png 703w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A multi-level index from a dictionary&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 663; --attachment-height: 396; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAoABABoJZwAA3AA%2FvJJK3EQIAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfb;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.webp 663w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;img alt="A naive multi-level index in a database" loading="lazy" decoding="async" class="attachment__image" width="663" height="396" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104111.png/663w.png 663w" sizes="(width &gt;= 768px) 520px, 50vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A naive multi-level index in a database&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt;
&lt;p&gt;B-trees are similar to multi-level indices in dictionaries, though they have
extra rules and nuances that aren&amp;#39;t important now.&lt;/p&gt;
&lt;p&gt;What&amp;#39;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;/p&gt;
&lt;p&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;/em&gt;&lt;/p&gt;
&lt;p&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&amp;#39;s what they do. Because of that, the
order of the columns becomes important.&lt;/p&gt;
&lt;p&gt;If you tell the database to build an index over &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and
then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt; it would take the two and concatenate them together like
so &lt;em&gt;&amp;quot;Person|443&amp;quot;&lt;/em&gt;. But if you tell it to build an index over &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt;
&lt;strong&gt;first and then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; it would concatenate them like so
&lt;em&gt;&amp;quot;443|Person&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The order of the columns in the index changes the structure of the index.&lt;/p&gt;
&lt;p&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&amp;#39;s
why it&amp;#39;s a general recommendation to put the most specific column first in a
composite index - it narrows down the search a lot.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1044; --attachment-height: 590; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvJXOwAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fcfcfc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.webp 1044w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naive composite database index with the ID column first and type column second" loading="lazy" decoding="async" class="attachment__image" width="1044" height="590" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401105701.png/1044w.png 1044w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;When the type column is first, there are a lot more rows to scan through in the
end.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1321; --attachment-height: 716; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAkABABoJZwAA3AA%2FvJSOppV8SAIAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fbfcfa;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/480w.webp 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/800w.webp 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.webp 1321w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Naive composite database index with the type column first and ID column second" loading="lazy" decoding="async" class="attachment__image" width="1321" height="716" src="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.png" srcset="https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/480w.png 480w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/800w.png 800w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1200w.png 1200w, https://media.stanko.io/variants/articles/why-does-rails-put-the-type-column-first-in-an-index-for-a-polymorphic-association-d1opdSyuh50B/pasted-image-20240401104647.png/1321w.png 1321w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;&lt;em&gt;Why would anyone create an index and put the type column first then?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;You don&amp;#39;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&amp;#39;t
have to scan through anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In a real app, you&amp;#39;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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Property&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:schedule_activations&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;grantor_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Schedule&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    class_name:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;AccessLog&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:active_people&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    through:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :access_logs&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    source:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :grantor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    source_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Person&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Calling&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Propert&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;42&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;schedule_activations&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Executes the following SQL statement&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```sql&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# SELECT COUNT(*)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# FROM access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# WHERE property_id = 42 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   AND grantor_type = &amp;#39;Schedule&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Calling&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Propert&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;42&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;active_people&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Executes the following SQL statement&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```sql&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# SELECT COUNT(tenants.*)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# FROM tenants INNER JOIN access_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   ON tenants.id = access_logs.grantor_id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# WHERE access_logs.property_id = 42&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   AND access_logs.grantor_type = &amp;#39;Person&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;In that case, the composite index over&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and then&lt;/strong&gt;
&lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&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;/p&gt;
&lt;p&gt;&lt;strong&gt;While the index over&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_type&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;first and then&lt;/strong&gt; &lt;em&gt;&lt;strong&gt;grantor_id&lt;/strong&gt;&lt;/em&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;/strong&gt;&lt;/p&gt;
&lt;p&gt;We can test this out.&lt;/p&gt;
&lt;p&gt;I created a test MariaDB database, with an access_logs table that I&amp;#39;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&amp;#39;ve named &lt;em&gt;test_index&lt;/em&gt;.&lt;/p&gt;
&lt;p&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;em&gt;&lt;strong&gt;test_index&lt;/strong&gt;&lt;/em&gt; &lt;strong&gt;to
generate the count&lt;/strong&gt;.&lt;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (1.209 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;                                                 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| id   | select_type | table         | type | possible_keys | key        | key_len | ref   | rows   | Extra                    |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|    1 | SIMPLE      | access_logs   | ref  | test_index    | test_index | 183     | const | 498290 | Using where; Using index |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+------+---------------+------------+---------+-------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.002 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| COUNT(1) |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|   333400 |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.042 sec)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; DROP INDEX test_index ON access_logs;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (0.045 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; CREATE INDEX test_index ON access_logs (grantor_id, grantor_type);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Query OK, 0 rows affected (1.146 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Records: 0  Duplicates: 0  Warnings: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; EXPLAIN  SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| id   | select_type | table         | type  | possible_keys | key        | key_len | ref  | rows   | Extra                    |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|    1 | SIMPLE      | access_logs   | index | NULL          | test_index | 192     | NULL | 996580 | Using where; Using index |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+------+-------------+---------------+-------+---------------+------------+---------+------+--------+--------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.000 sec)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;MariaDB [test]&amp;gt; SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| COUNT(1) |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;|   333400 |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;1 row in set (0.095 sec)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;These results will vary from database to database, but the principle still
stands.&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;postgres=# CREATE INDEX test_index ON access_logs (grantor_type, grantor_id);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;CREATE INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM access_logs WHERE grantor_type = &amp;#39;Person&amp;#39;;                                                                      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;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)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    Workers Planned: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	Workers Launched: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	-&amp;gt;  Partial Aggregate  (cost=5457.57..5457.58 rows=1 width=8) (actual time=10.314..10.314 rows=1 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	  -&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)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;	    Index Cond: (grantor_type = &amp;#39;Person&amp;#39;::text)                                      Heap Fetches: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Planning Time: 0.145 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Execution Time: 13.801 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# DROP INDEX test_index;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;DROP INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# CREATE INDEX test_index ON door_releases (grantor_id, grantor_type);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;CREATE INDEX&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;postgres=# EXPLAIN ANALYZE SELECT COUNT(1) FROM door_releases WHERE grantor_type = &amp;#39;Person&amp;#39;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;QUERY PLAN&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;------------------------------------------------------------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Finalize Aggregate  (cost=13778.29..13778.30 rows=1 width=8) (actual time=14.570..15.922 rows=1 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;   -&amp;gt;  Gather  (cost=13778.08..13778.29 rows=2 width=8) (actual time=14.566..15.919 rows=3 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         Workers Planned: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         Workers Launched: 2&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate  (cost=12778.08..12778.09 rows=1 width=8) (actual time=12.210..12.210 rows=1 loops=3)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;               -&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)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;                     Index Cond: (grantor_type = &amp;#39;Person&amp;#39;::text)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;                     Heap Fetches: 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Planning Time: 0.079 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt; Execution Time: 15.944 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Simply put.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;These are the two most common use cases that you&amp;#39;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;/p&gt;
&lt;p&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;/p&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&amp;#39;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;/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&amp;#39;s Encrypt&lt;/a&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;/p&gt;
&lt;p&gt;So I ran the official installer and got an error.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/usr/bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; once&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; setup&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 1111-1111-1111-1111&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ██████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ███╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ██████╗███████╗&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██╔═══██╗████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  ██║██╔════╝██╔════╝&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██║██╔██╗&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ██║██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;     █████╗&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;   ██║██║╚██╗██║██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;     ██╔══╝&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;╚██████╔╝██║&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚████║╚██████╗███████╗&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ╚═════╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚═╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  ╚═══╝&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; ╚═════╝╚══════╝&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                            &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Failed&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; to&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; start&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; the&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; software&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                            &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;                                                                                                                                                                                                                                              &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;The&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; software&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; couldn&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;t be started. Please try again. &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I expected the installer to fail since it can&amp;#39;t know my setup, and it didn&amp;#39;t ask
me anything besides the domain of the instance.&lt;/p&gt;
&lt;p&gt;Sadly, the email you get with your purchase doesn&amp;#39;t explain what the installer
does, and I can&amp;#39;t check what it&amp;#39;s doing since the installer is a binary blob. So
I had to snoop around for a bit.&lt;/p&gt;
&lt;p&gt;The first thing I found was the installer&amp;#39;s error log which said that the
container couldn&amp;#39;t bind to port 443.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;cat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; .once-error.log&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;2024/01/27&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 19:33:11&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; Error&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; daemon:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; driver&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; failed&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; programming&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; external&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; connectivity&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; endpoint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (e32f1a334f8ac25b9f3df0c283adb3eb3e8a554074f0827d17f8b566d03940a5): Bind &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;for&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; 0.0.0.0:443 failed: port is already allocated&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Since traefik is bound to port 80 and 443 this makes perfect sense.&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;em&gt;&amp;quot;registry.once.com/campfire:latest&amp;quot;&lt;/em&gt; image and a stopped
container which I promptly inspected.&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&amp;#39;s what&amp;#39;s bound to port 443 and 80 of the container.&lt;/p&gt;
&lt;p&gt;Inside the container is also a Redis instance, which implies that the installer
sets at least &lt;em&gt;&amp;quot;sysctl vm.overcommit_memory=1&amp;quot;&lt;/em&gt; else Redis wouldn&amp;#39;t start.&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;em&gt;&amp;quot;/root/.config/once/config.json&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; cat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; /root/.config/once/config.json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; jq&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;1111-1111-1111-1111&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;product_name&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;email_address&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;phony.email@example.com&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;ssl_domain&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;chat.rubyzg.org&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;validation_token&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_VALIDATION_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;secret_key_base&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_SECRET_KEY_BASE&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;vapid_private_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PRIVATE_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;vapid_public_key&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;PHONY_PUBLIC_KEY&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;storage_location&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/var/once/campfire&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;cron_hour&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; 2,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;  &amp;quot;once_binary_etag&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Seems pretty straight forward. This is roughly what the installer does&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; sysctl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; vm.overcommit_memory=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; run&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-d&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--restart&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; unless-stopped&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--env-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/.env&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-p&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 443&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	-p&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 80&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/storage:/rails/storage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	registry.once.com/campfire:latest&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	bin/boot&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;em&gt;&amp;quot;campfire/.env&amp;quot;&lt;/em&gt; contains the values from
&lt;em&gt;&amp;quot;/root/.config/once/config.json&amp;quot;&lt;/em&gt; plus a few extras.&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# campfire/.env &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;SECRET_KEY_BASE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_SECRET_KEY_BASE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;VAPID_PRIVATE_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_PRIVATE_KEY&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;VAPID_PUBLIC_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;PHONY_PUBLIC_KEY&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;SSL_DOMAIN&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;chat.rubyzg.org&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;DISABLE_SSL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;YES&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;To run Campfire without the installer, behind traefik, all I had to do is&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="bash"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sudo&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; sysctl&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; vm.overcommit_memory=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;docker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; run&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-d&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--network&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; apps&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--restart&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; unless-stopped&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--env-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/.env&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	--label-file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/labels&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/Procfile:/rails/Procfile&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/production.rb:/rails/config/environments/production.rb&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	-v&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string)"&gt; campfire/storage:/rails/storage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	registry.once.com/campfire:latest&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;	bin/boot&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;;;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Put this into &lt;em&gt;&amp;quot;campfire/labels&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# Enable traefik for the container &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.enable=true &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Convert HTTP traffic to HTTPS &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.entrypoints=websecure&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.tls.certresolver=letsencrypt&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Register the container to a domain - CHANGE THIS TO YOUR DOMAIN &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.routers.campfire.rule=Host(`chat.rubyzg.org`) &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# Return a service not available screen if Campfire isn&amp;#39;t running &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.middlewares.campfire.retry.attempts=10 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.middlewares.campfire.retry.initialinterval=1s &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.healthcheck.path=/up &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.server.port=80 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;traefik.http.services.campfire.loadbalancer.server.scheme=http &lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I had to disable thruster and bind Puma to port 80 by updating the
&amp;quot;web&amp;quot; entry in the Procfile (you get the source files in the purchase email)&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# campfire/Procfile &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;web: bin/rails server -b 0.0.0.0 -p 80 &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;# ... &lt;/span&gt;&lt;/span&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;strong&gt;but you won&amp;#39;t be able to post messages because Action Cable
can&amp;#39;t connect to the server&lt;/strong&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&amp;#39;t forward x-forwarded-for headers for
WebSocket handshakes&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;To fix that I had to update &lt;em&gt;&amp;quot;production.rb&amp;quot;&lt;/em&gt; with the exact URL that Action
Cable would get requests from&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# production.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;wss://&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SSL_DOMAIN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/cable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;allowed_request_origins&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;https://&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;SSL_DOMAIN&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That&amp;#39;s it.&lt;/p&gt;
&lt;p&gt;It&amp;#39;s isn&amp;#39;t as straight forward as I&amp;#39;d like it to be, and it would be nice if
Kamal and Campfire could play along nicely out of the box. I&amp;#39;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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 904; --attachment-height: 512; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmoAAABXRUJQVlA4IF4AAAAQAgCdASoQAAkABABoJYgC7AYrdUZ4nvgAAP7nrnz6oGPqMF5jwaNzNO8sd9e2vSYneASJNOjxHKqRQQAtsJW%2B3rlKKs2AyrzfwZlC86e8TxSHy3v67ejznKx3ngAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d8c6c4;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.webp 904w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Deconstructing Action Cable" loading="lazy" decoding="async" class="attachment__image" width="904" height="512" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/00011-2829697632.png/904w.png 904w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Deconstructing Action Cable&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;I knew what Action Cable was about and roughly how it worked since it was
released. But lately I&amp;#39;ve developed a much deeper understanding of it because I
had to extend it and explain its internals to others.&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;/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 id="from-http-to-websocket"&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;/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;The client can also push messages to the server through that WebSocket. But
that&amp;#39;s in my opinion less interesting as we could do that without Action Cable.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;How does the client connect to Action Cable&amp;#39;s WebSocket?&lt;/em&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 &amp;quot;/cable&amp;quot; and after a bit of back and forth
out pops a WebSocket.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;But how does a HTTP request become a Websocket?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Back in 1997 HTTP version 1.1 was released. It&amp;#39;s very similar to HTTP 1.0 with a
few additions, the one that&amp;#39;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;em&gt;&amp;quot;Upgrade&amp;quot;&lt;/em&gt;
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;/p&gt;
&lt;p&gt;And that&amp;#39;s exactly what a browser does when it connects to a WebSocket. It first
makes a request with the &lt;em&gt;&amp;quot;Upgrade&amp;quot;&lt;/em&gt; header set to &amp;quot;websocket&amp;quot; which tells the
server &amp;quot;hey, I want to switch to using WebSockets for this request&amp;quot;. 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&amp;#39;s connection.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 771; --attachment-height: 756; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAACwAQCdASoQABAABABoJaQAAudGNfQAAP7w1Qf6AAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #202020;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.webp 771w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A WebSocket upgrade request" loading="lazy" decoding="async" class="attachment__image" width="771" height="756" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124151038.png/771w.png 771w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A WebSocket upgrade request&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;But why do we need the HTTP request in the first place? Can&amp;#39;t we just connect
directly via the WebSocket protocol since we already know that the server
supports it?&lt;/em&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&amp;#39;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;/p&gt;
&lt;p&gt;&lt;em&gt;So Action Cable converts HTTP requests to WebSockets?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Yes! It&amp;#39;s a WebSocket server, but it also does so much more.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="connections"&gt;Connections&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;So someone&amp;#39;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;/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;/p&gt;
&lt;p&gt;When a client makes the initial HTTP request to &lt;em&gt;&amp;quot;/cable&amp;quot;&lt;/em&gt;, Action Cable will
take the request&amp;#39;s headers, cookies, URL and create a Connection object from it.&lt;/p&gt;
&lt;p&gt;If it exists, the Connection object will be an instance of
&lt;em&gt;&amp;quot;ApplicationCable::Connection&amp;quot;&lt;/em&gt;. If that class doesn&amp;#39;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;em&gt;&amp;quot;ActionCable::Connection::Base&amp;quot;&lt;/em&gt;&lt;/a&gt;.
You can change the class of the connection by setting
&lt;em&gt;&amp;quot;config.action_cable.connection_class = -&amp;gt;{ WhateverClassIWant }&amp;quot;&lt;/em&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;em&gt;&amp;quot;#connect&amp;quot;&lt;/em&gt;&lt;/a&gt;
method on our &lt;em&gt;&amp;quot;ApplicationCable::Connection&amp;quot;&lt;/em&gt; object will get called. In that
method you&amp;#39;d usually want to figure out who opened the connection, and reject it
if they aren&amp;#39;t allowed to open it.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Someone wants to connect via WebSocket!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;I don&amp;#39;t know who this is. Closing the WebSocket.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	    reject_unauthorized_connection&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Rails&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;debug&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;It&amp;#39;s, &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      person &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	    # We can access the param of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; request&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;params&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:token&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	    # We can access the session of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;token:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; request&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;session&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:person_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;        # We can access the cookies of the HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        session &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Session&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; cookies&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;encrypted&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:session_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        person &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; session&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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;em&gt;&amp;quot;#disconnect&amp;quot;&lt;/em&gt;&lt;/a&gt;
method that gets called when a WebSocket is closed. It&amp;#39;s useful if you want to
set something up in &lt;em&gt;&amp;quot;#connect&amp;quot;&lt;/em&gt; and then tear it down when the WebSocket
closes. E.g. if you&amp;#39;d want to count the number of open connections that a person
has, you could do something like this.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  reject_unauthorized_connection &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;increment!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; disconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;decrement!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now you know who the WebSocket connection is for, but Rails doesn&amp;#39;t. To tell
Rails which attr_accessor(s) we&amp;#39;ll use for identification we have to change our
&lt;em&gt;&amp;quot;attr_accessor&amp;quot;&lt;/em&gt; to
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/connection/identification.rb#L20"&gt;&lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&gt;&lt;/a&gt;,
which is a class method that Action Cable provides.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    #attr_accessor :current_person # CHANGE THIS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    identified_by &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # TO THIS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; connect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  set_current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  reject_unauthorized_connection &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Ok, now we&amp;#39;ve got a connection and we know who it&amp;#39;s for, how do we send a
message?&lt;/em&gt; Do we have to send some kind of JSON or XML or whatnot?&lt;/p&gt;
&lt;h2 id="the-wild-websocket-west"&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;/p&gt;
&lt;p&gt;As the &amp;quot;socket&amp;quot; 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&amp;#39;d like. It&amp;#39;s up to you to implement a protocol between your server and the
browser on top of your WebSocket.&lt;/p&gt;
&lt;p&gt;Luckily we don&amp;#39;t have to implement anything as &lt;strong&gt;Action Cable is not only a
WebSocket server but also a protocol.&lt;/strong&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 &amp;quot;message&amp;quot; together with some additional things I&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 775; --attachment-height: 232; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAABQAQCdASoQAAUABABoJQBOgCgAAP7vWp0RdhKYLb5VzuWlPsd0AA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #1f2d32;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.webp 775w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Raw message sent via ActionCable to the browser" loading="lazy" decoding="async" class="attachment__image" width="775" height="232" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240124152110.png/775w.png 775w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Raw message sent via ActionCable to the browser&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;Why do we need the wrapper Hash?&lt;/em&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;strong&gt;The &amp;quot;welcome&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
is the simplest of them all. It&amp;#39;s just a Hash with a key &amp;quot;type&amp;quot; that holds the
value &amp;quot;welcome&amp;quot;. All messages sent from the server to a client have a &amp;quot;type&amp;quot;
field. This is always the first message that a server sends to any client that
connects to it. It&amp;#39;s used to indicate to the client that the WebSocket is
functional.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;welcome&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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;strong&gt;The &amp;quot;ping&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
is used to keep a heartbeat. It&amp;#39;s sent to all connected clients every 3 seconds
so that the connection doesn&amp;#39;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&amp;#39;t receive a heartbeat message
within 6 seconds - that&amp;#39;s 2 heartbeats - it will try to reconnect. In addition
to the &amp;quot;type&amp;quot; field, a ping message will also have a &amp;quot;message&amp;quot; field which holds
the Unix timestamp of the moment the message was sent. This timestamp isn&amp;#39;t used
in the official client implementation.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ping&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1705848059&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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;strong&gt;The &amp;quot;disconnect&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
instructs a client to disconnect from the server. In addition to the &amp;quot;type&amp;quot;
field it has a &amp;quot;reconnect&amp;quot; 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 &amp;quot;reconnect&amp;quot; field there is a &amp;quot;reason&amp;quot; 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;em&gt;unauthorized&lt;/em&gt; - sent when &amp;quot;reject_unauthorized_connection&amp;quot; is called&lt;/li&gt;
&lt;li&gt;&lt;em&gt;invalid_request&lt;/em&gt; - sent when the initial HTTP request was malformed&lt;/li&gt;
&lt;li&gt;&lt;em&gt;server_restart&lt;/em&gt; - sent when the server is about to restart&lt;/li&gt;
&lt;li&gt;&lt;em&gt;remote&lt;/em&gt; - send when the client is kicked for whatever reason&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;disconnect&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;reconnect&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;reason&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;server_restart&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;To explain the &lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;reject_subscription&amp;quot;&lt;/em&gt; &amp;amp; &lt;em&gt;&amp;quot;unsubscribe&amp;quot;&lt;/em&gt; messages I first have to explain
Channels.&lt;/p&gt;
&lt;h2 id="channels"&gt;Channels&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;What are Channels?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Channels are Action Cable&amp;#39;s controllers.&lt;/p&gt;
&lt;p&gt;In a regular HTTP request you&amp;#39;d specify which method you want to call on which
Controller by setting the request&amp;#39;s path and method. E.g. if you&amp;#39;d want to call
the &lt;em&gt;&amp;quot;create&amp;quot;&lt;/em&gt; method of the &lt;em&gt;&amp;quot;ArticlesController&amp;quot;&lt;/em&gt; you&amp;#39;d make a POST request to
&amp;quot;/articles&amp;quot; and pass any parameters you&amp;#39;d like the new Article object to have.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ArticlesController&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # POST /articles&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Article&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(params&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:article&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;permit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:title&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      redirect_to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;action:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :show&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :see_other&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      render &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :unprocesssable_entity&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Chat&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	    poster:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;(Note that we get access to &lt;em&gt;&amp;quot;current_person&amp;quot;&lt;/em&gt; because we used &lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&gt;
in our Connection object)&lt;/p&gt;
&lt;p&gt;Now if you want to invoke &lt;em&gt;&amp;quot;ChatChannel#post_message&amp;quot;&lt;/em&gt; you first have to
subscribe to the &lt;em&gt;&amp;quot;ChatChannel&amp;quot;&lt;/em&gt; with a &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message.&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;strong&gt;The &amp;quot;subscribe&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
has a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field instead of a &lt;em&gt;&amp;quot;type&amp;quot;&lt;/em&gt; field. All messages sent from the
client to the server have a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field. In addition to that it also has
an &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; field which holds a JSON encoded Hash. The identifier Hash has
at least a &lt;em&gt;&amp;quot;channel&amp;quot;&lt;/em&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;em&gt;&amp;quot;params&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;subscribe&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;These params can be used however you&amp;#39;d like. E.g.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# PARAMS FROM THE SUBSCRIBE MESSAGE&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	    &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;messages&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	    &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;When someone subscribes to a Channel the &lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; method on the channel
will be called. In it you can do various things, one of which is to authorize
the subscription. Let&amp;#39;s say that you want to reject subscriptions for chat rooms
that a person isn&amp;#39;t a member of, you could do something like this&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; post_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	@chat_room&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;messages&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:content&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If the &lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; method doesn&amp;#39;t call &lt;em&gt;&amp;quot;reject&amp;quot;&lt;/em&gt; the server will respond
with a &lt;em&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/em&gt; message.&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;strong&gt;The&lt;/strong&gt; &lt;em&gt;&lt;code&gt;&amp;quot;confirm_subscription&amp;quot;&lt;/code&gt;&lt;/em&gt;
&lt;code&gt;message&lt;/code&gt;&lt;/a&gt;
has a &lt;em&gt;&amp;quot;type&amp;quot;&lt;/em&gt;, and an &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; field. The identifier holds the same value
that was sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;confirm_subscription&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;em&gt;&amp;quot;reject&amp;quot;&lt;/em&gt; was called then the server will respond with a
&lt;em&gt;&amp;quot;reject_subscription&amp;quot;&lt;/em&gt; message.&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;strong&gt;The &amp;quot;reject_subscription&amp;quot;
message&lt;/strong&gt;&lt;/a&gt;
has the same format as the &amp;quot;confirm_subscription&amp;quot; message.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;reject_subscription&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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;/p&gt;
&lt;p&gt;&lt;strong&gt;The &amp;quot;message&amp;quot; type messages&lt;/strong&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;/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;strong&gt;If the client is sending the
message&lt;/strong&gt;&lt;/a&gt;,
then the message will contain a &lt;em&gt;&amp;quot;command&amp;quot;&lt;/em&gt; field with the value &amp;quot;message&amp;quot;, an
&lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; and a &lt;em&gt;&amp;quot;data&amp;quot;&lt;/em&gt; field. Again, the identifier holds the same value
that was sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message. While data can be anything
depending on what the client has sent.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;data&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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;em&gt;&amp;quot;receive&amp;quot;&lt;/em&gt; method and if it does it will
invoke it and pass the message&amp;#39;s data to it.&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 &amp;quot;action&amp;quot; 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&amp;#39;s data.&lt;/p&gt;
&lt;pre class="highlight" data-language="json"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    \&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  }&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;quot;data&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    &amp;quot;action&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;post_message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    &amp;quot;content&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I&amp;#39;ll explain the server-to-client message format in a moment.&lt;/p&gt;
&lt;p&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;/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;/p&gt;
&lt;p&gt;A stream is a PubSub channel. I &lt;em&gt;guess&lt;/em&gt; it&amp;#39;s called a stream in Action Cable
because, semantically, this PubSub channel acts like a stream of messages for
the client.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;What is a PubSub channel?&lt;/em&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;In Action Cable, our Channel object is the subscriber and our application
(controllers, models, jobs, ...) is the publisher.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 979; --attachment-height: 274; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRigAAABXRUJQVlA4IBwAAAAwAQCdASoQAAQABABoJZwAA3AA%2FvF2g0MecCAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f2f9f7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.webp 979w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Illustration of how PubSub works" loading="lazy" decoding="async" class="attachment__image" width="979" height="274" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240128081709.png/979w.png 979w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Illustration of how PubSub works&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt;&lt;/a&gt;
which creates a PubSub channel using a String identifier that we pass to it
&lt;em&gt;(e.g. &amp;quot;chat:1337&amp;quot;)&lt;/em&gt;. Or
&lt;a href="https://github.com/rails/rails/blob/cc633481602daf004eaab845c3780c6d885f882c/actioncable/lib/action_cable/channel/streams.rb#L103"&gt;&lt;em&gt;“stream_for”&lt;/em&gt;&lt;/a&gt;
which accepts an object &lt;em&gt;(e.g. &amp;quot;Chat.find(1337)&amp;quot;)&lt;/em&gt;. &lt;em&gt;&amp;quot;stream_for&amp;quot;&lt;/em&gt; internally
generates a String identifier for the given object and calls &lt;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt;
with it.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # creates a PubSub channel to which we can publish messages to from anywhere&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    stream_for @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # the above is basicaly the same as&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # stream_from &amp;quot;chat_room:#{@chat_room.id}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that we have a stream we can publish messages to it from anywhere using
&lt;em&gt;&amp;quot;broadcast_to&amp;quot;&lt;/em&gt; which accepts two arguments - the object we are broadcasting
to, and the messages to broadcast.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broacast_to&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  ChatRoom&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # The same record that we gave to stream_for&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # The message I want to send&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you used &lt;em&gt;&amp;quot;stream_from&amp;quot;&lt;/em&gt; to create your stream, then you have to use
&lt;em&gt;&amp;quot;ActionCable.server.broadcast&amp;quot;&lt;/em&gt; instead of &lt;em&gt;&amp;quot;broadcast_to&amp;quot;&lt;/em&gt;. It also expects
two arguments - the stream we are broadcasting to, and the message we are
broadcasting.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;server&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;broadcast&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;chat_room:123&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&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;em&gt;&amp;quot;transmit&amp;quot;&lt;/em&gt;
method&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    transmit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Welcome back, &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{current_person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;first_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Ok, but what happens when we broadcast or transmit a message?&lt;/em&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&amp;#39;s wrapped in a hash containing two
keys&lt;/a&gt; -
&lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt; and &lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt;. The identifier holds the same value that was
sent in the original &lt;em&gt;&amp;quot;subscribe&amp;quot;&lt;/em&gt; message. While the message holds whatever you
are sending to the client.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;identifier&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;{ &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    \&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;channel\&amp;quot;: \&amp;quot;ChatChannel\&amp;quot;, &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    \&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;room_name\&amp;quot;: \&amp;quot;Ruby Zagreb\&amp;quot; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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&amp;#39;s for based on the &lt;em&gt;&amp;quot;identifier&amp;quot;&lt;/em&gt;, and then it can process the
&lt;em&gt;&amp;quot;message&amp;quot;&lt;/em&gt; however it likes.&lt;/p&gt;
&lt;h2 id="in-the-browser"&gt;In the browser&lt;/h2&gt;
&lt;p&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;/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;/p&gt;
&lt;p&gt;To connect to a server you have to create a &lt;em&gt;consumer&lt;/em&gt;, which is a wrapper
around a WebSocket connection to our server.&lt;/p&gt;
&lt;p&gt;You can create a consumer that will connect to &amp;quot;/cable&amp;quot; without any extra params
just by calling &lt;em&gt;&amp;quot;createConsumer&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;If you want to pass extra params to the server&amp;#39;s Connection object, e.g. for
authentication, you&amp;#39;ll have to pass the exact URL with params and all to
&lt;em&gt;&amp;quot;createConsumer&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Fetches the user&amp;#39;s auth token from a meta tag in the DOM&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; document&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;meta[name=auth_token]&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)?.content&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Takes the current URL,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// changes it&amp;#39;s path to &amp;quot;/ws&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and adds &amp;quot;?token=#{token}&amp;quot; as the query params.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// The result looks something like &amp;quot;https://example.com/ws?token=123&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; URL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;window&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;location&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.href)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.pathname &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;/ws&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; // usually it&amp;#39;s &amp;quot;/cable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.search &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `?token=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;token&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webSocketURL&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.toString&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;())&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now that you have a consumer, you can subscribe with it to any channel you&amp;#39;d
like. To subscribe, you have to create a subscription with an identifier.&lt;/p&gt;
&lt;p&gt;If you remember from before, an identifier has to have a &lt;em&gt;&amp;quot;channel&amp;quot;&lt;/em&gt; field, but
can also have any additional fields you want and you&amp;#39;ll have access to these
fields as &lt;em&gt;&amp;quot;params&amp;quot;&lt;/em&gt; in your Channel object.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;How do I process an incoming message with this?&lt;/em&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;em&gt;&amp;quot;received&amp;quot;&lt;/em&gt; method of the subscription
object&lt;/a&gt;
to process the message.&lt;/p&gt;
&lt;p&gt;You can create a received method on the subscription yourself, like so&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;received&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  document&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    .querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#messages&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    ?.insertAdjacentHTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;beforeend&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; `&amp;lt;div&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&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&amp;#39;ll miss that message.&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 class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Subscribe to the ChatChannel &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// and pass { room_name: &amp;quot;Ruby Zagreb&amp;quot; } as params&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.appendMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    appendMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.messageContainer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;?.insertAdjacentHTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;		&amp;quot;beforeend&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;		`&amp;lt;div&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    messageContainer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  document&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.querySelector&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;#messages&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&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;/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;em&gt;&amp;quot;initialized&amp;quot;&lt;/em&gt; method, rejection a &lt;em&gt;&amp;quot;rejected&amp;quot;&lt;/em&gt; method, connect and
disconnect a &lt;em&gt;&amp;quot;connected&amp;quot;&lt;/em&gt; and &lt;em&gt;&amp;quot;disconnected&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called right after the Subscription object is created&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    initialized&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`A subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; was created`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called if the subscription was rejected by the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	rejected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`The server rejected the subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when the subscription gets confirmed by the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    connected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(data) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // The data object has a single property `reconnected`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // which indicates if this was a resubscribe after a disconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`The server confirmed the subscription to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.identifier&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;! Reconnected: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.reconnected&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when the WebSocket closes&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	disconnected&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(data) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // The data object has a single propert `willAttemptReconnect`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;	  // which indicates if a reconnect attempt will be made or not&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`WebSocket to &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; closed! Will attempt reconnect: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.willAttemptReconnect&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    // Called when a message is sent from the server&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`Received message: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&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;/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;em&gt;&amp;quot;unsubscribe&amp;quot;&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    received&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`Received message: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;${&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;`&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.toLowerCase&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;() &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;===&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;avada kedavra&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.unsubscribe&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&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;em&gt;&amp;quot;send&amp;quot;&lt;/em&gt;
method&lt;/a&gt;
with the message you want to send.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { createConsumer } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;@rails/actioncable&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; createConsumer&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; consumer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscriptions&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  { channel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ChatChannel&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Ruby Zagreb&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;subscription&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ action&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;post_message&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Hello, Zagreb!&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; })&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h2 id="thousands-of-connections-one-server"&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;/p&gt;
&lt;p&gt;&lt;em&gt;Does that mean that there is a maximum number of connections that Action Cable
can handle?&lt;/em&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;/p&gt;
&lt;p&gt;&lt;em&gt;How does that work?&lt;/em&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&amp;#39;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;/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&amp;#39;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;/p&gt;
&lt;p&gt;&lt;em&gt;But Action Cable turns requests into WebSockets, which can stay open for days,
how don&amp;#39;t we run out of threads in the pool?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That&amp;#39;s the interesting part. &lt;strong&gt;Action Cable processes only the initial HTTP
request in Puma&amp;#39;s thread pool.&lt;/strong&gt; After you get a 101 response from the server
the request is &amp;quot;hijacked&amp;quot; and put into an event loop. &lt;strong&gt;So Puma&amp;#39;s thread pool
determines the maximum number of WebSocket connections that can be opened at
once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hijack? Event loop? What?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Rails doesn&amp;#39;t integrate directly with an application server &lt;em&gt;(like Puma)&lt;/em&gt;.
Instead it implements Rack&amp;#39;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;/p&gt;
&lt;p&gt;Rack is great for request-response interactions like HTTP, but a WebSocket isn&amp;#39;t
request-response like. It&amp;#39;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&amp;#39;s called hijacking the socket.&lt;/p&gt;
&lt;p&gt;When you hijack a Rack request you get it&amp;#39;s underlying socket. You can read and
write bytes to the sockets freely. But this means that it&amp;#39;s your responsibility
to implement whatever protocol you want over that socket.&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&amp;#39;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;/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;/p&gt;
&lt;p&gt;In Action Cable&amp;#39;s case it&amp;#39;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;/p&gt;
&lt;p&gt;That&amp;#39;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;/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&amp;#39;s operating system&amp;#39;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;/p&gt;
&lt;p&gt;Once it&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1107; --attachment-height: 256; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAQABABoJZwAA3AA%2FvGIa9dymOSAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f7f7f7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/800w.webp 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.webp 1107w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Illustration of all the pools that a request goes through" loading="lazy" decoding="async" class="attachment__image" width="1107" height="256" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/800w.png 800w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240204081845.png/1107w.png 1107w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Illustration of all the pools that a request goes through&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;em&gt;Another thread pool?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Yes. &lt;strong&gt;This worker pool allows Action Cable to process multiple incoming
messages simultaneously.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can tweak the size of this worker pool by setting
&lt;em&gt;&amp;quot;config.action_cable.worker_pool_size&amp;quot;&lt;/em&gt; to the number of threads you&amp;#39;d like to
have in that pool.&lt;/p&gt;
&lt;p&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;. 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;/p&gt;
&lt;p&gt;But there is one more thread pool in Action Cable - the event loop thread pool.&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&amp;#39;ll get to in just a bit).&lt;/p&gt;
&lt;p&gt;This thread pool has a fixed size as it&amp;#39;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 id="things-i-wish-i-knew-right-away"&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;/p&gt;
&lt;p&gt;&lt;strong&gt;You can trigger actions and send messages periodically&lt;/strong&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;/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;em&gt;&amp;quot;periodically&amp;quot;&lt;/em&gt; 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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; StockTickerChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # this will send a message to the client every 2 seconds &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # as long as they are subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  periodically &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;every:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;seconds&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    transmit &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;value:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @stock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; timestamp:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Time&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;now&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_i&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @stock &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Stock&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;symbol:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:symbol&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @stock&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Some error trackers won&amp;#39;t catch errors from your Channel objects unless you
explicitly send them.&lt;/strong&gt; You can do that in your Connection using
&lt;em&gt;&amp;quot;rescue_from&amp;quot;&lt;/em&gt;, just like you would in a Controller.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    rescue_from &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Exception&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;error&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      MyErrorTracker&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;capture_exception&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(error)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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; You can register such a callback using
&lt;em&gt;&amp;quot;before_command&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;after_command&amp;quot;&lt;/em&gt; and &amp;quot;around_command&amp;quot;, 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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ActionCable::Connection&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Base&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    around_command &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;set&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;person:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;yield&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;There are callbacks on the Channel object for when someone subscribes or
unsubscribes.&lt;/strong&gt; There is &amp;quot;&lt;em&gt;before_subscribe&lt;/em&gt;&amp;quot;, &lt;em&gt;&amp;quot;after_subscribe&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;around_subscribe&amp;quot;&lt;/em&gt;, and &amp;quot;&lt;em&gt;before_unsubscribe&lt;/em&gt;&amp;quot;, &lt;em&gt;&amp;quot;after_unsubscribe&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;around_unsubscribe&amp;quot;&lt;/em&gt;. These are useful for implementing common behavior
through inheritance or mixins without having to call &amp;quot;super&amp;quot; form the
&lt;em&gt;&amp;quot;subscribed&amp;quot;&lt;/em&gt; or &lt;em&gt;&amp;quot;unsubscribed&amp;quot;&lt;/em&gt; method.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; AppearanceTrackable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  extend&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveSupport&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Concern&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  included &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	after_subscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;unless:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :subscription_rejected?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;came_online!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;	end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	after_unsubscribe &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;	  Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;went_offline!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ChatChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; AppearanceTrackable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @chat_room &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; current_person&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;chat_rooms&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;	  .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:room_name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	reject &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    stream_for @chat_room&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;You probably want to bump Puma&amp;#39;s worker timeout if you are debugging your
Connection object.&lt;/strong&gt; Puma will kill any worker that doesn&amp;#39;t generate a response
within 60 seconds.&lt;/p&gt;
&lt;p&gt;This can be annoying if you are trying to debug something with &lt;em&gt;&amp;quot;debugger&amp;quot;&lt;/em&gt;,
&lt;em&gt;&amp;quot;binding.irb&amp;quot;&lt;/em&gt; or &lt;em&gt;&amp;quot;binding.pry&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;But you can raise that timeout using the &lt;em&gt;&amp;quot;worker_timeout&amp;quot;&lt;/em&gt; method in
&lt;em&gt;&amp;quot;puma.rb&amp;quot;&lt;/em&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/puma.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;expand_path&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;../config/environment&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; File&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;dirname&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(__FILE__))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Kill a worker thread if it didn&amp;#39;t generate a responsw in 8 hours&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;worker_timeout &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;8&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; *&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3600&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_ENV&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;development&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;development&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;You have to tweak both Puma&amp;#39;s thread and Action Cable&amp;#39;s worker counts.&lt;/strong&gt;
Puma&amp;#39;s thread count controls how many new WebSocket connections can be created
simultaneously. Action Cable&amp;#39;s worker pool size determines how many WebSocket
messages you can process simultaneously. Both pools will create database
connections!&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/puma.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;max_threads_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MAX_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;5&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;min_threads_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MIN_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { max_threads_count }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;threads min_threads_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; max_threads_count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# config/application.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;config&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;action_cable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;worker_pool_size&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ENV&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;RAILS_MAX_THREADS&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;5&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;In development, sometimes the server can behave a bit wonky.&lt;/strong&gt; I&amp;#39;m not a 100%
sure what&amp;#39;s going on but it seems like code reloading can cause rogue threads
with stale code. If that happens just run &amp;quot;touch tmp/restart.txt&amp;quot; to quickly
restart the server and reset everything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When a client looses it&amp;#39;s Internet connection it will take Action Cable up to
20 min to notice that, close the connection, and trigger callbacks.&lt;/strong&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;/p&gt;
&lt;p&gt;&lt;strong&gt;You can use the official JS client outside of the browser, like in Node or
Bun.&lt;/strong&gt; The official client doesn&amp;#39;t use &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket"&gt;the browser&amp;#39;s WebSocket
object&lt;/a&gt; directly. It
exports an &lt;em&gt;&amp;quot;adapters&amp;quot;&lt;/em&gt; object which has a property called &lt;em&gt;&amp;quot;WebSocket&amp;quot;&lt;/em&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;/p&gt;
&lt;p&gt;&lt;strong&gt;You can remotely disconnect any client.&lt;/strong&gt; There is a
&lt;em&gt;&amp;quot;ActionCable::RemoteConnections&amp;quot;&lt;/em&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;em&gt;&amp;quot;where&amp;quot;&lt;/em&gt;
method&lt;/a&gt;
to get a connection. The where method searches for a connection by its
&lt;em&gt;&amp;quot;identified_by&amp;quot;&lt;/em&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;em&gt;&amp;quot;disconnect&amp;quot;&lt;/em&gt;&lt;/a&gt;
which then disconnects the client.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Person&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Applicationrecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ban!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;banned:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    ActionCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;RemoteConnections&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;      .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;current_person:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      &amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;disconnect&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;reconnect:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;There are multiple PubSub adapters available besides Redis.&lt;/strong&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 class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;# config/cable.yml&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;production:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  adapter: postgres&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h2 id="final-thought"&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 537; --attachment-height: 955; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAAAwAQCdASoJABAABABoJZQAA3AA%2FvDi%2BOqfhhQ9wKuSbMi1tuHkAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f7e9ee;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/480w.webp 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.webp 537w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Block overview of everything discussed" loading="lazy" decoding="async" class="attachment__image" width="537" height="955" src="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png" srcset="https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/480w.png 480w, https://media.stanko.io/variants/articles/deconstructing-action-cable-DC7F33OsjGmK/pasted-image-20240205082517.png/537w.png 537w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Block overview of everything discussed&lt;/figcaption&gt; &lt;/figure&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: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;/p&gt;
&lt;p&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; EventsChannel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationCable&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;Channel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; subscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;came_online&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    stream_from &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;device&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; unsubscribed&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Current&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;went_offline&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; dependent:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :destroy&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  scope &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:online&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;online:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  scope &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:offline&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;online:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; came_online&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;online:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :online&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; went_offline&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;	update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;online:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :offline&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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&amp;#39;s name, and if I disconnect the orb turns red.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 2049; --attachment-height: 1080; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAACwAQCdASoQAAkABABoJaQAAxYPNY6AAP7zimCYfcnVHG0us%2FWYAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f6f6f6;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_demo.jpg/1200w.jpg" width="2049" height="1080"&gt; &lt;source src="https://media.stanko.io/originals/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_demo.webm" type="video/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;
&lt;p&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&amp;#39;t connected to WiFi
anymore.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1918; --attachment-height: 1080; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAACQAQCdASoQAAkABABoJZwAAlwYiUgA%2FvTmcmF1eZV%2F%2BR1WgSekJA%2BOSRSFgAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #eeeeee;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_offline_demo_av1.jpg/1200w.jpg" width="1918" height="1080"&gt; &lt;source src="https://media.stanko.io/originals/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_offline_demo_av1.webm" type="video/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;p&gt;What is going on?&lt;/p&gt;
&lt;p&gt;Well, most operating systems keep a connection &amp;quot;alive&amp;quot; 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&amp;#39;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;/p&gt;
&lt;p&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;Since I want to know when a device disconnects as soon as possible this buffer
and the delay it introduces is a problem.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Why didn&amp;#39;t the heartbeat save me this trouble? I had to dive deep into
ActionCable to figure out.&lt;/p&gt;
&lt;p&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&amp;#39;t become
stale, and if one side doesn&amp;#39;t receive the heartbeat in time it knows that it
has lost connection to the other side. The heartbeat messages are commonly
called &amp;quot;ping&amp;quot; and &amp;quot;pong&amp;quot;. 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&amp;#39;t get into.&lt;/p&gt;
&lt;p&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&amp;#39;t receive a ping or pong back within a given time
frame they assume that the other side has disconnected.&lt;/p&gt;
&lt;p&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&amp;#39;t expect a pong back.&lt;/p&gt;
&lt;p&gt;That&amp;#39;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&amp;#39;t know that the client has disconnected
abruptly, so it can&amp;#39;t close the connection until either the OS buffer overflows
or times out.&lt;/p&gt;
&lt;p&gt;In my case, just adding a pong response to the client, and the ability for the
server to close any connection that didn&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1920; --attachment-height: 1080; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjgAAABXRUJQVlA4ICwAAACQAQCdASoQAAkABABoJZwAAhZox0AA%2FvTfvVZ0Q35tlR%2B8rxm%2B1Y3GKAAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #edeced;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_pong_monkeypatch.jpg/1200w.jpg" width="1920" height="1080"&gt; &lt;source src="https://media.stanko.io/originals/articles/tracking-online-presence-with-actioncable-ukEi6sUZ98Bz/presence_app_pong_monkeypatch.webm" type="video/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;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;t a pong
response, instead it&amp;#39;s a switch to client-initiated heartbeats.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The issues highlight the following as advantages of client initiated heartbeats:&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 &amp;quot;&lt;em&gt;What do we do when we detect a dropped message?&lt;/em&gt;&amp;quot;
which is application-level stuff and a major expansion of the framework, so I
won&amp;#39;t delve into these features.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;Because of this, I abandoned client-initiated heartbeats and returned to the
original pong message idea.&lt;/p&gt;
&lt;p&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&amp;#39;t support pong responses to still
function, while clients that know about pong responses get improved presence
detection.&lt;/p&gt;
&lt;p&gt;This is forwards-compatible (&lt;em&gt;client that don&amp;#39;t send pong messages can connect
to servers that expect them&lt;/em&gt;), and somewhat backwards-compatible (&lt;em&gt;clients that
do send pong messages can connect to servers that don&amp;#39;t expect them&lt;/em&gt;) if you
don&amp;#39;t mind the error log messages that this will cause (&lt;em&gt;it won&amp;#39;t crash the
WebSocket&lt;/em&gt;).&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;t support pong
messages.&lt;/p&gt;
&lt;p&gt;Luckly, WebSockets provide a mechanism for exactly that - it&amp;#39;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&amp;#39;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&amp;#39;s response with just the chosen
protocol. With that both the server and the client know exactly how to
communicate over the WebSocket.&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;The only thing left to do now was to open a PR to Rails.&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;t well
documented, I stumbled upon this by accident when I intentionally wrote a
failing test.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;With that out of the way, there is one more edge case that I&amp;#39;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&amp;#39;t.&lt;/p&gt;
&lt;p&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&amp;#39;s decremented. If the count is
zero, the device is offline, if it&amp;#39;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&amp;#39;t change the count incorrectly.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationRecord&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  has_many &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; dependent:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :destroy&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  scope &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:online&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;connection_count:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;...&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  scope &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:offline&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;connection_count:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 0&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; came_online&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    with_lock &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;     old_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; reload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection_count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;     update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;connection_count:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; old_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :online&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; old_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;zero?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; went_offline&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	with_lock &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;     old_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; reload&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;connection_count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;     update!(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;connection_count:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; old_count &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;-&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;	online_status_changes&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :offline&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; connection_count&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;zero?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And that&amp;#39;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;p&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&amp;#39;t been to Amsterdam
since 2019 when I briefly meandered through it&amp;#39;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&amp;#39;ve never been to Rails-specific conference before.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/2385w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2385; --attachment-height: 2746; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2014%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmAAAABXRUJQVlA4IFQAAAAQAgCdASoOABAABABoJbACdAD6l8xO0dQAAP7ur%2Byy1xRwAoOrEcqG4mIEyS2ITlHZmOiWGiIMzbOToQ56Mi6MKbcGBtzZ2Q130x5Fm6dA1GwoAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #633134;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/2385w.webp 2385w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Rails World banner" loading="lazy" decoding="async" class="attachment__image" width="2385" height="2746" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_banner.jpeg/2385w.jpg 2385w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Rails World banner&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAAAwAgCdASoMABAABABoJQBOgCHw83ja4sM7gAD%2B1JmjhzUOLnCK%2FX3lT5DF9YiibLJXPz7rqSt6my5TzVHs8z1tdgnK9LohLgAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8a929b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Beurs van Berlage" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_beurs_van_berlage.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Beurs van Berlage&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/2268w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2268; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkAAAABXRUJQVlA4IDQAAADQAQCdASoJABAABABoJaACdADc6bSSCAD8xmcjX5dUPxTCNBTHhN%2BFmVqrzg9%2B6OdKVuAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #a17780;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/2268w.webp 2268w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The sunset over Amsterdam as seen from the rooftop lobby of the Zoku hotel" loading="lazy" decoding="async" class="attachment__image" width="2268" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_sunset.jpeg/2268w.jpg 2268w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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 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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlgAAABXRUJQVlA4IEwAAAAwAgCdASoMABAABABoJYgCdAE1kelmOqazAAD%2BqKgg2ntOh62mdmmqzeeZ8QDAGTmASTif5KHqL7oAvXmNCsRjo5RRTwa2kotEAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7c6a5b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="My room in the Zoku Hotel Amsterdam" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_room.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;My room in the Zoku Hotel Amsterdam&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The next morning, at 8:30 am, I made my way back to Beurs Van Berlage. There
weren&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmwAAABXRUJQVlA4IGAAAACwAQCdASoMABAABABoJQBOgBuM9LQQAP3DBMgSXPaVk4bA8PzQSGAt%2FwsQN7UTSaCtVeddSnZj7eFpWyPmw6p96cGkjABa%2BIqX6Xp2KO62ywZTTIcDjcWQwh8%2FvbCvAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #695c59;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The ceiling of the Beurs van Berlage" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/beurs_van_berlage_ceiling.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The ceiling of the Beurs van Berlage&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRl4AAABXRUJQVlA4IFIAAADQAQCdASoMABAABABoJbAC7ADzMF6iAAD%2B8rTt8V6jjkl6e9VGA%2BDvESJZBzw9O662OPDpnHeqPv%2F64BVnehubAaZ7eZMxzN0KXmQBPXA338AA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #4e2430;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The stage on Track 1 of Rails World" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/railsworld_stage.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The stage on Track 1 of Rails World&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;t the crazy one after all.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Generally, the easiest thing to do is to refresh a page when something updates -
that&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I can’t wait for the videos to come so that I can watch both talks in full.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;He started off with introducing all the problems of modern project management
and development and drew analogs from that.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;There were so many nuggets of wisdom in this talk, it’s well worth the watch
when the video comes out.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I met a lot of new people, and a few old friends.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4032; --attachment-height: 3024; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2012%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlgAAABXRUJQVlA4IEwAAACwAQCdASoQAAwABABoJbACdACpqHAAAPg7gDPLlOr8IeTzMKbKRknwhsd02wbN3FHI4knKOMOi%2FezgHpwxN2Xv3nVUZGRUNW8TyoAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #5fa1a8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Remote Ruby podcast sticker" loading="lazy" decoding="async" class="attachment__image" width="4032" height="3024" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/remoteruby_sticker.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Remote Ruby podcast sticker&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2483; --attachment-height: 2669; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRn4AAABXRUJQVlA4IHIAAABwAgCdASoPABAABABoJbACdH8AFlimlGy4SnQAAPdyu6TTI7%2BSyeUKbFivJ3d8%2F6WxTw09353lCZpinoVyIddT%2BG8PJplpUtmnaBvHVEAP4qpbOaqXo39951cF%2FtTwQVWpm2%2F7Fl5WH3X69FlJIpQ2gAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #65737a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Rails' 20th anniversary pin" loading="lazy" decoding="async" class="attachment__image" width="2483" height="2669" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/rails_20th_anniversary_pin.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Rails&amp;#39; 20th anniversary pin&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The museum was huge. There are three floors full of his and his contemporaries&amp;#39;
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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRl4AAABXRUJQVlA4IFIAAADwAQCdASoMABAABABoJbACdAEQz%2B8VtkAA%2FpRMSW%2BSdGQODPpYq6aEkUDjIQoHaYX%2Bxb9uOlQZohLbl61TbbZ%2F2Hx1E77Y7oyO9mlA2qsy2AAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #9e8d6a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="My girlfriend in front of the sunflowers by Van Gogh" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/karla_with_the_sunflowers.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;My girlfriend in front of the sunflowers by Van Gogh&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2860; --attachment-height: 3705; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmIAAABXRUJQVlA4IFYAAADwAQCdASoMABAABABoJYgCdAEN3vQ3sAAA%2FPembtAl6FEsiYneAUdISi9eUqfA9Fmo4Xa9lFQKam7L4afPqjEnauKli6NhAEZF8Vc22N61wvvdOJnwAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #897e7a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Pikachu with a hat, drawn in the style of Van Gogh" loading="lazy" decoding="async" class="attachment__image" width="2860" height="3705" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/van_gogh_pikatchu.jpg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRnAAAABXRUJQVlA4IGQAAAAQAgCdASoMABAABABoJQAB8ink1UXZo3wwAPucpqb9Z0PU%2FcUZrbyP1U3r%2Bg8jNx8gwnK5hBTkTqbrRZ7ZczpLtxXxp2nFrnXtu0dzmqIvYRmN4A4sM3G3XHb0S0h3%2F27xAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #626851;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Gian lily pads from the tropical glass house at the De Hortis" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/giant_liliypads.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;There was a very beautiful glass house with all sorts of plants.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmAAAABXRUJQVlA4IFQAAABQAgCdASoMABAABABoJYwC7ApgAy8gbW7P%2BYAA%2Fq0TEv1ND80abAMiTu4CFoIsaRSaMwDQj89%2BXAfU9Jf9ngVF%2FLyTTqZZ9hkQXrCGxCuVy68cAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #716e60;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A glass house at the De Hortis" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/glass_house.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A glass house at the De Hortis&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;And a glass house that served as a nursery for butterflies with dozens of them
flying about pollinating plants.&lt;/p&gt;
&lt;p&gt;By now it was already late in the afternoon so I went back to the hotel to pack
up.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/2268w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2268; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAADwAQCdASoJABAABABoJZQAAtzkeeJiZAAA3mW1KVkZXoGwYzvGBoWXaysJnf5Vb1BYnzcvig1shRF38QN%2B1xIAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #979891;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/2268w.webp 2268w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Walkway from the elevator to the lobby of the Zoku hotel" loading="lazy" decoding="async" class="attachment__image" width="2268" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/zoku_walkway.jpeg/2268w.jpg 2268w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;The next morning I made my way to Schiphol airport and then back to Zagreb.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmQAAABXRUJQVlA4IFgAAAAwAgCdASoMABAABABoJbACdAEPh86hoO87MAD%2BhAeTdVK07WWFnYMWQQu5cHfpUKgT67peJ7DKcnlBr2k%2FXPMJ3EBa5xg9eP4jZncvF4716ICubkph9aAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #70644f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Terminal B36 at Schiphol Airport" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/rails-world-sPmrX4SDRCa5/shiphol_terminal_b36.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Terminal B36 at Schiphol Airport&lt;/figcaption&gt; &lt;/figure&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;p&gt;After two years, this week I finally caved and changed my current employment
title on LinkedIn from Software Engineer to Software Architect.&lt;/p&gt;
&lt;p&gt;This isn’t a position where I&amp;#39;m am supposed to do much programming - the thing I
love most, and the thing I&amp;#39;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;&amp;quot;It&amp;#39;s easy to hold
poison Ivy in somebody else&amp;#39;s hands&amp;quot;&lt;/em&gt; (that&amp;#39;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&amp;#39;s the NSFW
original&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;That proverb describes a decision made by someone who isn&amp;#39;t involved in the
execution of the decision, and doesn&amp;#39;t suffer its consequences.&lt;/p&gt;
&lt;p&gt;I don&amp;#39;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;/p&gt;
&lt;p&gt;I’ve had &amp;quot;managers&amp;quot; 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;/p&gt;
&lt;p&gt;And I know that engineering isn&amp;#39;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;/p&gt;
&lt;p&gt;The problem with Software Architects, or any purely managerial position, is that
you aren&amp;#39;t eating your own dog food, and therefore you don&amp;#39;t live with the
consequences of your decisions. So you produce &lt;em&gt;misfit solutions&lt;/em&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;/blockquote&gt;
&lt;p&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;/p&gt;
&lt;p&gt;When I&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;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&amp;#39;t any misfit solutions.&lt;/p&gt;
&lt;p&gt;This isn&amp;#39;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;/p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I immediately started looking for a new place on Njuskalo (a local classified
ads site).&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;After some searching I found a few reasonably priced apartments and arranged to
go see them.&lt;/p&gt;
&lt;h2 id="chapter-1-paradigms--marketing"&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;em&gt;“What is the ground floor
of a building anyway?”&lt;/em&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;There were two camps of people; one believes that the ground floor is what&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;s fever dreams and just didn&amp;#39;t know
it yet.&lt;/p&gt;
&lt;p&gt;How could a fundamental fact like this be subject to relative interpretation? I
tried to see the the other camp&amp;#39;s perspective - to understand their paradigm -
but no matter how I sliced it this was just complete madness.&lt;/p&gt;
&lt;p&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?&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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&amp;#39;, on the 2nd floor counting
from the street entrance”.&lt;/p&gt;
&lt;p&gt;Of course this wasn’t madness, this was just marketing.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;(seriously though, in my last building the co-owner&amp;#39;s counsel couldn&amp;#39;t agree on
the color of the front gate for 2 months and then they just gave up and let the
gate rust)&lt;/p&gt;
&lt;p&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 id="chapter-2-lies-lies-lies"&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&gt;So landlords started lying, and over night all buildings got 50 years younger. A
case so curious that Benjamin Button would be surprised.&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;This was the worst lie I encountered, but it wasn’t the only one.&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&gt;Wooden windows become PVC windows. Two single pane windows become double pane
windows. And stuff like that.&lt;/p&gt;
&lt;p&gt;The funniest lie I saw was a &amp;quot;Modern *rustic* apartment in the center&amp;quot; which
was actually an apartment in which somebody&amp;#39;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&amp;#39;s, they charged for it like the
place was freshly renovated, and to explain that they just slapped on &amp;quot;rustic&amp;quot;
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 id="chapter-3-upkeep-not-included"&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;strong&gt;consume&lt;/strong&gt; the
utility that I pay for.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;s expense without having to renegotiate rent.&lt;/p&gt;
&lt;h2 id="chapter-4-parking-in-my-apartment"&gt;Chapter 4: Parking in my apartment&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.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Turns out that this is completely normal, and has been like this since forever.
But to me this is utterly insane.&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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&amp;#39;t there. Or imagine one of your neighbors grilling
on their parking space, or building a shed, or something like that...&lt;/p&gt;
&lt;p&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 id="chapter-5-contracts-and-xenophobia"&gt;Chapter 5: Contracts and Xenophobia&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;t enough) in
case you miss payment or owe them anything. This is in addition to a one month’s
rent as insurance.&lt;/p&gt;
&lt;p&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.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;When I asked why this was needed I got just one reply from all landlords “We had
bad experiences with foreigners“.&lt;/p&gt;
&lt;p&gt;So… xenophobia and lies.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;So this is again just another new way of shifting risk from the landlord to the
tenant.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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…&lt;/p&gt;
&lt;p&gt;This isn&amp;#39;t normal, and this was never normal. Avoid such contracts at all cost.
If you really like the place offer up two month&amp;#39;s rent as insurance and see if
they go for it.&lt;/p&gt;
&lt;h2 id="chapter-6-living-in-a-forest"&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;t have a reason to own a car).&lt;/p&gt;
&lt;p&gt;I decided to go for it, and I’m so glad I did.&lt;/p&gt;
&lt;p&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&amp;#39;s much easier
to recharge and take a break than it was in a city apartment.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/1200w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1200; --attachment-height: 1600; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4IEoAAACwAQCdASoMABAABABoJQBOg7QAPXAAAP5JMDloLPWiM6hPtYco%2FYuRNPccE6yVsRd8qn3xuM4deK4NGz%2Bub6eVRXbKaXIG396AAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #2f231f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/480w.webp 480w, https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/800w.webp 800w, https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/1200w.webp 1200w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="View from the balcony at night" loading="lazy" decoding="async" class="attachment__image" width="1200" height="1600" src="https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/480w.jpg 480w, https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/800w.jpg 800w, https://media.stanko.io/variants/articles/rent-in-zagreb-is-hell-lxSb30fFZmQq/img_4052.jpg/1200w.jpg 1200w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;View from the balcony at night&lt;/figcaption&gt; &lt;/figure&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;
&lt;p&gt;If thought corrupts language, language can also corrupt thought&lt;/p&gt;
&lt;p&gt;- George Orwell&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;One thing that annoyed me anywhere I worked was that people limit themselves to
a narrow subset of English. A subset that&amp;#39;s full of buzzwords and phrases that
sound impressive but lack meaning. It&amp;#39;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&amp;#39;t
mimic George Orwell&amp;#39;s Newspeak more even if it tried, and therefore I call it
&amp;quot;Corporate newspeak&amp;quot;.&lt;/p&gt;
&lt;p&gt;What annoys me is that speaking with someone in corporate newspeak feels like we
are speaking in memes. We aren&amp;#39;t saying what we think, we aren&amp;#39;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;/p&gt;
&lt;p&gt;Take &amp;quot;We don&amp;#39;t have the bandwidth&amp;quot; as an example. What does that sentence mean?
It could be &amp;quot;The network switches in our server farm can&amp;#39;t handle any more
network traffic&amp;quot;, or it could be &amp;quot;There isn&amp;#39;t anyone available to work on this
project in the next month, we would have to pause another project&amp;quot;, or &amp;quot;Your
project isn&amp;#39;t something I think we should invest time in right now&amp;quot;. 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;/p&gt;
&lt;p&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
&amp;quot;We have an exciting announcement. From next quarter our Widget 3000 will come
in two new colors - blue and green&amp;quot; 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 &amp;quot;exciting&amp;quot;. If
everything is exciting then nothing is exciting - it rings hollow.&lt;/p&gt;
&lt;p&gt;Some find newspeak to be sincere, but I don&amp;#39;t and maybe that&amp;#39;s a culture
difference. I find only language that I would use every-day sincere. And I don&amp;#39;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&amp;#39;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&amp;#39;d us the formal &amp;quot;You&amp;quot; instead of the regular &amp;quot;you&amp;quot;
and you&amp;#39;d use &amp;quot;Greetings&amp;quot; instead of &amp;quot;Hi&amp;quot; when addressing them.&lt;/p&gt;
&lt;p&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&amp;#39;s a
very high price for conflict avoidance.&lt;/p&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;/p&gt;
&lt;p&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&amp;#39;s servers. And the HTML itself had a lot of
formatting and hidden elements specific to Medium and SEO.&lt;/p&gt;
&lt;p&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 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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;The first &amp;quot;problem&amp;quot;&lt;/strong&gt; I ran into were attachments. ActionText uses Rails&amp;#39; 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;/p&gt;
&lt;p&gt;This wasn&amp;#39;t an issue and was as simple as iterating through all
&lt;code&gt;Article::Attachment&lt;/code&gt; records, taking their attachment, calling &lt;code&gt;to_io&lt;/code&gt; on it
and then passing that to &lt;strong&gt;ActiveStorage::Blob.create_and_upload!&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;blob &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveStorage&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Blob&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create_and_upload!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  io:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; attachment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;attachment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_io&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  filename:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; attachment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;attachment_data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;dig&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metadata&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;filename&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  content_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; attachment&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;attachment_data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;dig&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;metadata&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;mime_type&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  identify:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The second &amp;quot;problem&amp;quot;&lt;/strong&gt; I ran into was paragraph support. ActionText doesn&amp;#39;t
use paragraphs (p elements) for text, instead it puts all text into a single div
element and breaks it into &amp;quot;paragraphs&amp;quot; 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&amp;#39;s Typography
plugin&lt;/a&gt; and its &lt;strong&gt;prose&lt;/strong&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;The third problem&lt;/strong&gt; was converting my previously rendered markdown into
ActionText&amp;#39;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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Nokogiri&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;HTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(article&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rendered_markdown&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;tap&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Remove the article&amp;#39;s title&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;css&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;h1&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;first&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;remove&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Convert all paragraphs into a DIV with BRs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  content &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;css&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;p&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;first&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;div&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  node &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; content&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  while&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (node = node&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;next_sibling&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    content&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;inner_html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; +=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;lt;br&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{node&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;inner_html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    node&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;remove&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Convert images to attachments&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;css&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;img&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;node&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    node&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;action-text-attachment&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  article&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;update!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;content:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The fourth problem&lt;/strong&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&amp;#39;t ideal, but it
works and it&amp;#39;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&amp;#39;t worth it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fifth &amp;quot;problem&amp;quot;&lt;/strong&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; RichTextHelper&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; transform_rich_text&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;rich_text&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; transforms: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; transforms&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;blank?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      transforms &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; methods&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;select&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ends_with?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;_rich_text_transform&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;        .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;gsub&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;/_rich_text_transform$/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    document &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Nokogiri&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;HTML&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(rich_text&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    transforms&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;reduce&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(document) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;doc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; transform&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      public_send(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{transform}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;_rich_text_transform&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; doc)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Makes code blocks look prettier / more readable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; highlight_code_blocks_rich_text_transform&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;document&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    document&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;css&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;pre&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;code_block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      code &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; code_block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      formatter &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Rouge&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Formatters&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;HTML&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      lexer &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Rouge&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Lexer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;guess&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({ &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;source:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; code })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      code_block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;inner_html&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; formatter&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;format&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(lexer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;lex&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(code))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      code_block[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;class&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [code_block[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;class&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;highlight&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;select&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:present?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;join&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    document&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# then in some view&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;%=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; transform_rich_text(@article&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;content)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; %&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Though my initial idea of hooking into ActionText led me to read it&amp;#39;s source
code, because of which I&amp;#39;m now much more familiar with the framework. So this
detour wasn&amp;#39;t a waste time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The final &amp;quot;problem&amp;quot;&lt;/strong&gt; were link previews. That&amp;#39;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&amp;#39;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 &amp;quot;frontend&amp;quot; feature
and they don&amp;#39;t work if someone disabled JS or blocks it. But I&amp;#39;m still unsure it
that was a good idea and I might move the code to setup previews into the
helper.&lt;/p&gt;
&lt;p&gt;All in all, I love using ActionText and I&amp;#39;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;/p&gt;
&lt;p&gt;ActionText was easy to integrate, and with hindsight I can say that it was also
easy to migrate to it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;P.S. while reading through ActionText&amp;#39;s source and issues&lt;/em&gt; &lt;a href="https://github.com/basecamp/trix/issues/626#issuecomment-800338265"&gt;&lt;em&gt;I ran across an
interesting quote form the author of
markdown&lt;/em&gt;&lt;/a&gt;&lt;em&gt;:&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&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;/p&gt;
&lt;p&gt;- &lt;a href="https://en.wikipedia.org/wiki/John_Gruber"&gt;John Gruber&lt;/a&gt;&lt;/p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2512; --attachment-height: 752; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjIAAABXRUJQVlA4ICYAAABwAQCdASoQAAUABABoJZQCw7FAAAD%2B0Df%2F3S91gkdHKGTUkSAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8f959a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/480w.webp 480w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/800w.webp 800w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="2512" height="752" src="https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/1200w.png" srcset="https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/480w.png 480w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/800w.png 800w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/1200w.png 1200w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/1600w.png 1600w, https://media.stanko.io/variants/articles/vanilla-rails-view-components-with-partials-41zctBGbN9ka/untitled.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;
&lt;p&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;/p&gt;
&lt;p&gt;The &lt;code&gt;component&lt;/code&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;code&gt;render&lt;/code&gt; with
&lt;code&gt;&amp;quot;components/#{component_name}&amp;quot;&lt;/code&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;/p&gt;
&lt;p&gt;For now, this is what the helper looks like&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ComponentHelper&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; component&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; options&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    full_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Pathname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;components&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; path&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    render(full_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; options)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;So &lt;code&gt;component(&amp;quot;map&amp;quot;, longitude: 15.9765701, latitude: 45.8130054)&lt;/code&gt; is the same
thing as
&lt;code&gt;render(&amp;quot;components/map&amp;quot;, longitude: 15.9765701, latitude: 45.8130054)&lt;/code&gt;.&lt;/p&gt;
&lt;p&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/matches/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Winners&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Rank&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Player&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Score&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;%= @match.winners.each do |player| %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Losers&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Rank&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Player&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Score&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;%= @match.losers.each do |player| %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/matches/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Winners&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- we are passing `@match.winners` as the local `players` to the partial --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;players_table&amp;quot;, players: @match.winners %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Losers&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;players_table&amp;quot;, players: @match.losers %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/matches/_players_table.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Rank&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Player&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Score&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;th&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;thead&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    &amp;lt;!-- the `players` local shows up in this partial as the `players` variable --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;%= render collection: players, partial: &amp;quot;matches/player&amp;quot; %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    &amp;lt;!-- the above ^ is a shorthand for:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    ```&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;% players.each do |player| %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;        &amp;lt;%= render &amp;quot;matches/player&amp;quot;, player: player %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    ```&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tbody&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;table&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/matches/_player.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.rank %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.name %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= player.score %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;td&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;tr&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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;/p&gt;
&lt;p&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;content_box&amp;quot;, content_partial: &amp;quot;weekly_metrics&amp;quot;, content_options: { team: @team } %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;content_box&amp;quot;, content_partial: &amp;quot;monthly_metrics&amp;quot;, content_options: { team: @team } %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/_content_box.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;%= render content_partial, content_options %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/_weekly_metrics.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches won: &amp;lt;%= team.matches.where(created_at: (1.week.ago...)).won.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches lost: &amp;lt;%= team.matches.where(created_at: (1.week.ago...)).lost.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Upcoming matches: &amp;lt;%= team.matches.where(created_at: (Time.current...)).count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/_monthly_metrics.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches won: &amp;lt;%= team.matches.where(created_at: (1.month.ago...)).won.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches lost: &amp;lt;%= team.matches.where(created_at: (1.month.ago...)).lost.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;content_box&amp;quot; do %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches won: &amp;lt;%= @team.matches.where(created_at: (1.week.ago...)).won.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches lost: &amp;lt;%= @team.matches.where(created_at: (1.week.ago...)).lost.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Upcoming matches: &amp;lt;%= @team.matches.where(created_at: (Time.current...)).count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;%= render &amp;quot;content_box&amp;quot; do %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches won: &amp;lt;%= @team.matches.where(created_at: (1.month.ago...)).won.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Matches lost: &amp;lt;%= @team.matches.where(created_at: (1.month.ago...)).lost.count %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/teams/_content_box.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;%= yield %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  &amp;lt;!-- `yield` will be replaced by whatever is passed in the block --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;That’s how &lt;code&gt;component(&amp;quot;content_box&amp;quot;, title: &amp;quot;Location&amp;quot;) do&lt;/code&gt; works.&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% component(&amp;quot;content_box&amp;quot;, title: &amp;quot;Location&amp;quot;) do %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Hello World!&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/components/_content_box.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;rounded shadow bg-white&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  &amp;lt;!-- `local_assigns` is a hash that contains all the locals passed to a partial --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  &amp;lt;!-- we can check if a local is present and access it&amp;#39;s value through it --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;% if local_assigns.key?(:title) %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text-gray-900 text-lg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;%= local_assigns[:title] %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;!-- I could have used `title` instead of `local_assigns[:title]` --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;%= yield %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This brings me back to the second purpose of the &lt;code&gt;component&lt;/code&gt; method - fixing
localization.&lt;/p&gt;
&lt;p&gt;If I would use full localization keys for everything then there wouldn’t be a
problem with just using &lt;code&gt;render&lt;/code&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% render(&amp;quot;components/content_box&amp;quot;, title: t(&amp;quot;properties.show.location&amp;quot;)) do %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= t(&amp;quot;properties.show.hello_world&amp;quot;) %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- config/locales/en.yml --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;en:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  properties:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    show:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      location: &amp;quot;Location&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      hello_world: &amp;quot;Hello World!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- rendered HTML --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;rounded shadow bg-white&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text-gray-900 text-lg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Location&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Hello World!&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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 class="highlight" data-language="html"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- app/views/properties/show.html.erb --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% render(&amp;quot;components/content_box&amp;quot;, title: t(&amp;quot;.location&amp;quot;)) do %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&amp;lt;%= t(&amp;quot;.hello_world&amp;quot;) %&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;% end %&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- config/locales/en.yml --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;en:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  properties:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    show:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      location: &amp;quot;Location&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      hello_world: &amp;quot;Hello World!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;&amp;lt;!-- rendered HTML --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;rounded shadow bg-white&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;text-gray-900 text-lg&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;Location&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h4&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;!-- This is what Rails render when a translation is missing --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;!-- Notice that it looked for the translation in `en.components.content_box.hello_world` --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      &amp;lt;!-- instead of in `en.properties.show.hello_world` --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;translation_missing&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; title&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;translation missing: en.components.content_box.hello_world&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        Hello World&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;span&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;h1&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  &amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;div&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;lt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;section&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;&amp;gt;&lt;/span&gt;&lt;/span&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;/p&gt;
&lt;p&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;code&gt;components.content_box&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;code&gt;capture&lt;/code&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;code&gt;render&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here is the full &lt;code&gt;ComponentHelper&lt;/code&gt; code with the relative locale keys fix.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;module&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ComponentHelper&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; component&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; locals&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;block&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    full_path &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Pathname&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;components&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;/&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; path&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; block&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # Render the passed block within the current context and&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # store it in the `content` variable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      content &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; capture &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        block&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # Call render but pass it a new block that yields just&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      # the contents of the `content` variable&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      render(full_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; locals)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { content }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      render(full_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_s&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; locals)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And that’s all there is to it - &lt;code&gt;render&lt;/code&gt;, &lt;code&gt;local_assigns&lt;/code&gt;, &lt;code&gt;yield&lt;/code&gt; and
&lt;code&gt;capture&lt;/code&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The game makes two key decisions for you - the difficulty level and the story
exposition.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/1155w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1155; --attachment-height: 963; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2013%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAACQAQCdASoQAA0ABABoJQBOgCB6yAAA%2FvJu0EZPAMX9ZHPXor6EVNQT2itqlNGurcCDJDFZsWMW1igLvVVaWAWWH8jmIyg0Esxle2qc8AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #e4ded2;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/480w.webp 480w, https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/800w.webp 800w, https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/1155w.webp 1155w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="1155" height="963" src="https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/1155w.png" srcset="https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/480w.png 480w, https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/800w.png 800w, https://media.stanko.io/variants/articles/elden-ring-9aC2HBpWCnoG/meme.png/1155w.png 1155w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Have I been putting the carriage before the horse all this time?&lt;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Like every group chat ever, a channel will spawn off-topic conversations that
will ping everyone in the channel.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;You&lt;/p&gt;
&lt;p&gt;Know&lt;/p&gt;
&lt;p&gt;The&lt;/p&gt;
&lt;p&gt;Type&lt;/p&gt;
&lt;p&gt;Of&lt;/p&gt;
&lt;p&gt;People&lt;/p&gt;
&lt;p&gt;Who&lt;/p&gt;
&lt;p&gt;Send&lt;/p&gt;
&lt;p&gt;Messages&lt;/p&gt;
&lt;p&gt;Like&lt;/p&gt;
&lt;p&gt;This&lt;/p&gt;
&lt;p&gt;Just&lt;/p&gt;
&lt;p&gt;So&lt;/p&gt;
&lt;p&gt;They&lt;/p&gt;
&lt;p&gt;Can&lt;/p&gt;
&lt;p&gt;Write&lt;/p&gt;
&lt;p&gt;Something&lt;/p&gt;
&lt;p&gt;Instantly&lt;/p&gt;
&lt;p&gt;Then there is the other kind of instant in instant messaging - the instant
response - to which some people feel entitlement.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Some of these “features” were technological or practical limitations of the
time, and some evolved out of the way people used email.&lt;/p&gt;
&lt;p&gt;But email evolved into a great, distraction free, way to communicate by using
these friction points to its advantage.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Email is still the best communication method for me.&lt;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But I found a way to illustrate the problems and processes that cause tech debt
through Factorio.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1280; --attachment-height: 720; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAABQAQCdASoQAAkABABoJQBOgCgAAP7vwAxxGGsDgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #312d14;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_intro-2400.jpg/1200w.jpg" width="1280" height="720"&gt; &lt;source src="https://media.stanko.io/originals/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_intro-2400.webm" type="video/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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1280; --attachment-height: 720; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvOdgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #000000;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_crafting.jpg/1200w.jpg" width="1280" height="720"&gt; &lt;source src="https://media.stanko.io/originals/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_crafting.webm" type="video/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;p&gt;Iron plates enable you to build drills and conveyor belts. Now you don’t have to
dig and walk so much anymore.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1280; --attachment-height: 720; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvOdgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #000000;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_automation.jpg/1200w.jpg" width="1280" height="720"&gt; &lt;source src="https://media.stanko.io/originals/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/factorio_blogpost_automation.webm" type="video/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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--webm"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1280; --attachment-height: 720; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoQAAkABABoJaQAA3AA%2FvOdgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #000000;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.jpg/1200w.jpg" width="1280" height="720"&gt; &lt;source src="https://media.stanko.io/originals/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.webm" type="video/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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/893w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 893; --attachment-height: 489; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjYAAABXRUJQVlA4ICoAAACQAQCdASoQAAkABABoJZACdAB%2FeoAA%2Ftlvp6uWJrqGo7pL7BTfnriAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #4d4325;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/480w.webp 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/800w.webp 800w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/893w.webp 893w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A furnace and two assemblers connected with conveyors, building new conveyors" loading="lazy" decoding="async" class="attachment__image" width="893" height="489" src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/893w.png" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/480w.png 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/800w.png 800w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled.png/893w.png 893w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Now you have two choices - tear everything down and rebuild it better, or
squeeze in a few conveyors.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/699w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 699; --attachment-height: 644; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2015%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAACQAQCdASoQAA8ABABoJYmR5RgAiwAA%2FuzWf6eYqsKj5G8mPRm%2BYYAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #534326;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/480w.webp 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/699w.webp 699w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="699" height="644" src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/699w.png" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/480w.png 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_1.png/699w.png 699w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/985w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 985; --attachment-height: 1041; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2015%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjQAAABXRUJQVlA4ICgAAABwAQCdASoPABAABABoJYgCdAFAAAD%2B7ZgcmNezPfWsFJc3g6m7mAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #4b4021;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/480w.webp 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/800w.webp 800w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/985w.webp 985w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="985" height="1041" src="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/985w.png" srcset="https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/480w.png 480w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/800w.png 800w, https://media.stanko.io/variants/articles/tech-debt-for-non-techies-4rQhGJdxiMuY/untitled_2.png/985w.png 985w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;In their eyes one is fact, the other is a hypothetical. While in the builder’s
eyes both are fact.&lt;/p&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;p&gt;Seeking perfection is a fool’s errand.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Perfection is the siren song that will steer you into a vortex.&lt;/p&gt;
&lt;p&gt;So fight it, seek reality, seek better, seek good enough.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&gt;Luckily, it didn’t catch fire. Later that day I opened up the laptop to see what
happened.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But this problem got me thinking - was the MacBook Pro really a professional
tool?&lt;/p&gt;
&lt;p&gt;I always think of a DSLR camera when I think of a professional tool.&lt;/p&gt;
&lt;p&gt;Its parts are mostly replaceable, and the parts that aren’t have redundancies.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Yet there still exist cheaper point-and-shoots which offer similar image quality
but without the modularity for less money.&lt;/p&gt;
&lt;p&gt;As a developer, my most used tool is a computer. And for a long time my go to
computer was a laptop.&lt;/p&gt;
&lt;p&gt;In the laptop space modularity and repairability have been dead and buried long
ago.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The question is the progress of what? And what design?&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The only progress I see here is the progress of companies maximizing profits.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Today it’s becoming harder and harder to find a computer that allows you to fix
or upgrade anything.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I wouldn’t call these devices professional.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAADQAQCdASoMABAABABoJbACdADyV4BZAAD%2BM8Jmit3PKUOySyn84oo85gq5BiR6%2BuRpx2cw77kCfawMjADw%2FY72NmMAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #446ca5;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Above the clouds somewhere between Zagreb and Munich" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/231529ff-ace2-41ac-9b92-17df332ff25f.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Above the clouds somewhere between Zagreb and Munich&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmQAAABXRUJQVlA4IFgAAAAQAgCdASoMABAABABoJagCdACv8lt%2FAl0AANyhmLf1f7rB2UgSkbSxWr5XnObiIleB8pzvkrO79eGiNLPPpuzK43ovHE%2F8sIL8kMCyLPbzfxkgZl6qeaAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #817660;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Statues at the Helsinki railway station" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/8dc90c0d-a958-423e-af94-84005dc5e9a9.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Statues at the Helsinki railway station&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/2132w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2132; --attachment-height: 3530; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2010%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAADQAQCdASoKABAABABoJZgAApyfXFwUAAD%2B7N6oQ6QLvGvS1VImKC8EstDWmLD5fbKAayU4AAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #43212a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/2132w.webp 2132w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A tractor turned into a dining table in a room dimly lit by red light" loading="lazy" decoding="async" class="attachment__image" width="2132" height="3530" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/e932f629-a185-495f-9e8b-5429bad99ffc.jpeg/2132w.jpg 2132w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&gt;The following day was the first day of EuRuKo.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2887; --attachment-height: 3926; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAADQAQCdASoMABAABABoJbACdACUhLt%2FsADKN3h%2Fw%2FYMJiqAwqsc37Dzrq15BEpQJmJibxpzbnOhXB2%2BBnGvMlDywKxlvCKyKrQ1axogAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #753f60;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Inside of the ornate room where the conference was held" loading="lazy" decoding="async" class="attachment__image" width="2887" height="3926" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/4319989f-6ce8-44cd-b105-d9c52271c8a2.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Inside of the ornate room where the conference was held&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;All talks are &lt;a href="https://2022.euruko.org/videos/"&gt;already available for everyone to
watch&lt;/a&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 1080; --attachment-height: 1920; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%209%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlwAAABXRUJQVlA4IFAAAADQAQCdASoJABAABABoJbACdAELW%2BIrMAD%2B743cG3ng7OVqy2qm2kBgldG%2BkciSA0yQNNhyYRfVWEHFywnIqFTo4YTQSc3qJ8EG%2FCeTDQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6e5f50;" controls autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/koreanbbq2.jpg/1080w.jpg" width="1080" height="1920"&gt; &lt;source src="https://media.stanko.io/originals/articles/a-week-in-helsinki-3XbBLSiAuFKy/koreanbbq2.mp4" type="video/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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1536w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1536; --attachment-height: 2048; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAADQAQCdASoMABAABABoJZQAAuZ5khoMgAD%2B6z%2BmJZ%2BXHrjOBYuqRnGEqAUcJA4DbxzelZrxOrQd9tSQgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8e978b;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1536w.webp 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A view of Helsinki from the harbor" loading="lazy" decoding="async" class="attachment__image" width="1536" height="2048" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/2da6f8a1-576f-46c8-9937-51e4edd3f5aa.jpeg/1536w.jpg 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A view of Helsinki from the harbor&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/2048w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2048; --attachment-height: 1536; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2012%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkYAAABXRUJQVlA4IDoAAAAwAgCdASoQAAwABABoJZQCdAEO9Ea980TEAAD%2B518bkHzMLAE7uCAsmOFoInk%2FOWEB0h9eDZFxwAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #909b9e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/2048w.webp 2048w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A beautiful, red painted, wooden house on a small island right outside the Helsinki harbor" loading="lazy" decoding="async" class="attachment__image" width="2048" height="1536" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/509d914e-75c1-401e-bf39-579088cbda86.jpeg/2048w.jpg 2048w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2975; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmAAAABXRUJQVlA4IFQAAADwAQCdASoMABAABABoJYwCdAD7EVGJdgAA%2BLVfJXE8nvUJRUMHyYrry7aonVQTKQW1ZvTuh7CEiTKM%2FwFRWJ1wNRDsIHsBug2CoIGijZVEZFpH0AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #887d7c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A pinkish-red tower of a building with a clock in it" loading="lazy" decoding="async" class="attachment__image" width="2975" height="4032" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/de8fa187-7dbe-4642-ac2a-1699c91cc2ac.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3024; --attachment-height: 4032; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRloAAABXRUJQVlA4IE4AAACwAQCdASoMABAABABoJbACdADdf9y4AP7v7Zn9QHzS0Znw2UecqdkeUvbgzeJECyZSmsyiYSq2c39GORYABVv3j1gYJPOKY2tom%2BRygAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #a48e67;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Two bright-yellow buildings of the naval academy" loading="lazy" decoding="async" class="attachment__image" width="3024" height="4032" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/0134ebcc-6453-4947-b2ed-5c49323c49c7.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Two bright-yellow buildings of the naval academy&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1536w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1536; --attachment-height: 2048; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmIAAABXRUJQVlA4IFYAAAAQAgCdASoMABAABABoJYgCdAELVtcEzDwAAPzmGHNf6khW2uDOxsJzQ3UqgMr0xREhiwbRmYJ3Yc7c5EUJ8K%2BvDITnj6BL6kWHycT7Uf6F75BQWuAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #917e73;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1536w.webp 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A brick and mortar building from the center of the island." loading="lazy" decoding="async" class="attachment__image" width="1536" height="2048" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/b087ae75-5014-4173-bb57-3b3d841ecf39.jpeg/1536w.jpg 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;And there is even a submarine!&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1536w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1536; --attachment-height: 2048; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlgAAABXRUJQVlA4IEwAAADwAQCdASoMABAABABoJQBdgBufMQHa7oAA%2Fug%2BxydEvJGXicXA0N2MfSVVlqi9DStHW2TIN0S%2FNMgR%2BQvHci3imfAkoYJr5%2B63I0AA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #a09d93;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1536w.webp 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A submarine in a dry dock" loading="lazy" decoding="async" class="attachment__image" width="1536" height="2048" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/71e79c51-1865-4568-9624-52bf6f67a3b1.jpeg/1536w.jpg 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A submarine in a dry dock&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2972; --attachment-height: 3963; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkwAAABXRUJQVlA4IEAAAADwAQCdASoMABAABABoJZQCdAD0Ntd0twAA%2Fu9h%2Bz%2BAm4Or0e03l2MvgpRl%2FhZSNHfBM7BNLH5crxFw8rOJ4AAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #443f35;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The inside of a hollowed out hill" loading="lazy" decoding="async" class="attachment__image" width="2972" height="3963" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/d757bf2a-7099-43b2-9f03-592b95477073.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The inside of a hollowed out hill&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4032; --attachment-height: 3024; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2012%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAADwAQCdASoQAAwABABoJQBOgCHSVlPO4gAA%2Fuq2FklwCTUAj04AHEA88%2FOOnoXLrakcryDJwpwRKuZogAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #989d9a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A cliff covered with in green moss, grass, and trees, looking over the sea" loading="lazy" decoding="async" class="attachment__image" width="4032" height="3024" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/44d7de29-e92c-4cd2-a8a9-cf12fa142692.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2954; --attachment-height: 4030; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlIAAABXRUJQVlA4IEYAAACwAQCdASoMABAABABoJYgAAqyFNrsIAP6Ru7%2BGHAZYiqFZlsXtVMtB%2FhURZfA0vjJfvjuLHbVNq4O6zOrRzfoh%2FgmK0AAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7e816f;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="2954" height="4030" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/99956906-1e18-48aa-95eb-00d3ce3ed208.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1536w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1536; --attachment-height: 2048; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAAAQAgCdASoMABAABABoJZQC7AEPDxipSegAAP7tXlYFoRPXo8NEDtEG6H6s9YOPupEs9Xord76GpodLOXx35bs98AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #9ea19e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1536w.webp 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Helsinki Cathedral - a tall, white palace with green domed roofs decorated with golden stars" loading="lazy" decoding="async" class="attachment__image" width="1536" height="2048" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/266f023d-1751-4cc4-8d93-18349911407d.jpeg/1536w.jpg 1536w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&gt;Everyone we met was kind, helpful and trusting.&lt;/p&gt;
&lt;p&gt;I was surprised how quiet everybody is. Compared to my hometown everyone seems
to be whispering all the time.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2859; --attachment-height: 3902; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRl4AAABXRUJQVlA4IFIAAAAwAgCdASoMABAABABoJbACdAEYFRcRW%2B9ywAD%2BdJ1K5zZ7JVtTTEQaS83Di3DrY4h%2FuwNUcbVD%2FGuf1tG41uoV4Bx0AQA3JMCgVzfU9EsIsvAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8f7d65;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A white notebook with a gold embossed Moomin" loading="lazy" decoding="async" class="attachment__image" width="2859" height="3902" src="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/a-week-in-helsinki-3XbBLSiAuFKy/a445d99b-c5d7-43d7-8095-1111c2c4f2df.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A white notebook with a gold embossed Moomin&lt;/figcaption&gt; &lt;/figure&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; index&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  @events &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;EventLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                            .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;preload&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :device&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                            .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strict_loading&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                            .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;page&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(params[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:page&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                            .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;per&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;25&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h3 id="so-why-did-the-view-render-so-slow"&gt;So why did the view render so slow?&lt;/h3&gt;
&lt;p&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 class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;Started GET &amp;quot;/events&amp;quot; for 172.23.0.1 at 2022-10-10 08:57:51 +0000&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Processing by EventsController#index as HTML&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  Parameters: {}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Device::EventLog Load (2.8ms)  SELECT `device_event_logs`.* FROM `device_event_logs` LIMIT 25 OFFSET 0&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;User Load (79.75ms)  SELECT `users`.* FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` IN (97212, 2526, ...)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Device Load (79.95ms)  SELECT `devices`.* FROM `devices` WHERE `devices`.`deleted_at` IS NULL AND `devices`.`id` IN (8368, 137, ...)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;↳ /app/views/events/index.html.erb:10:in `_app_views_events_index_html_erb___1845829941033837665_155040&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;Completed 200 OK in 251ms (Views: 78.7ms | ActiveRecord: 162.5ms | Allocations: 226640)&lt;/span&gt;&lt;/span&gt;&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;/p&gt;
&lt;h3 id="but-where-does-the-where-deleted_at-is-null-come-from"&gt;But where does the WHERE deleted_at IS NULL come from?&lt;/h3&gt;
&lt;p&gt;I looked at the &lt;strong&gt;User&lt;/strong&gt; and &lt;strong&gt;Device&lt;/strong&gt; models. Both invoke the
&lt;code&gt;acts_as_paranoid&lt;/code&gt; method which the &lt;a href="https://github.com/rubysherpas/paranoia"&gt;paranoia
gem&lt;/a&gt; adds to &lt;code&gt;ActiveRecord::Record&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Paranoia knows which records are deleted by checking a &lt;code&gt;deleted_at&lt;/code&gt; column that
has to be added to each model that can act as paranoid. When the record is
deleted, &lt;code&gt;deleted_at&lt;/code&gt; is set to the current timestamp. A record can be restored
(un-deleted) by setting &lt;code&gt;deleted_at&lt;/code&gt; back to &lt;strong&gt;nil&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Paranoia filters out deleted records by adding &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; to all
queries.&lt;/p&gt;
&lt;h3 id="why-are-the-queries-to-fetch-the-users-and-devices-so-slow"&gt;Why are the queries to fetch the users and devices so slow?&lt;/h3&gt;
&lt;p&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;code&gt;explain&lt;/code&gt; 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;/p&gt;
&lt;p&gt;Here is the explain result for one of the slow queries:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  97212&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2526&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 15027&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 11982&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10846&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  17091&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 19528&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10731&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7785&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 23240&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  23238&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7244&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7221&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 6993&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 11959&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  16931&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 38234&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7006&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 8993&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 6766&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  39565&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 31681&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7088&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7786&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 34029&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;explain&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&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;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&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;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# | id | select_type | table | type | possible_keys                     | key                       | key_len | ref   | rows | Extra                 |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# |  1 | SIMPLE      | users | ref  | PRIMARY,index_users_on_deleted_at | index_users_on_deleted_at | 6       | const | 25   | Using index condition |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+------+-----------------------------------+---------------------------+---------+-------+------+-----------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# 1 row in set (0.00 sec)&lt;/span&gt;&lt;/span&gt;&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;code&gt;PRIMARY&lt;/code&gt; and &lt;code&gt;index_users_on_deleted_at&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Out of these two it choose to use &lt;code&gt;index_users_on_deleted_at&lt;/code&gt;, which is a bit
odd since the query mostly filters by IDs.&lt;/p&gt;
&lt;h3 id="why-did-it-choose-that-index-instead-of-using-the-primary-key-and-why-is-that-so-slow"&gt;Why did it choose that index instead of using the primary key? And why is that so slow?&lt;/h3&gt;
&lt;p&gt;I ran &lt;code&gt;SHOW INDEX&lt;/code&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;code&gt;index_users_on_deleted_at&lt;/code&gt; instead of &lt;code&gt;PRIMARY&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;These are the statistics for the &lt;code&gt;users&lt;/code&gt; table:&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+----&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| Table   | Non_unique | Key_name                    | Seq_in_index | Column_name  | Collation | Cardinality | ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+-----&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| &amp;quot;users&amp;quot; | 0          | &amp;quot;PRIMARY&amp;quot;                   | 1            | &amp;quot;id&amp;quot;         | &amp;quot;A&amp;quot;       | 3202800     | ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;| &amp;quot;users&amp;quot; | 1          | &amp;quot;index_users_on_deleted_at&amp;quot; | 1            | &amp;quot;deleted_at&amp;quot; | &amp;quot;A&amp;quot;       | 4003        | ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;+---------+------------+-----------------------------+--------------+--------------+-----------+-------------+-----&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;Cardinality&lt;/strong&gt; column shows that the &lt;code&gt;PRIMARY&lt;/code&gt; index has millions of values
indexed, while &lt;code&gt;index_users_on_deleted_at&lt;/code&gt; only has a few thousand.&lt;/p&gt;
&lt;h3 id="but-why-is-that-index-so-slow"&gt;But why is that index so slow?&lt;/h3&gt;
&lt;p&gt;The decision by the query planner to use &lt;code&gt;index_users_on_deleted_at&lt;/code&gt; felt off.
It seemed like a mistake so I dug deeper.&lt;/p&gt;
&lt;p&gt;MariaDB offers query optimizer tracing, which is fancy term for a “detailed
explanation why it did what it did”.&lt;/p&gt;
&lt;p&gt;I opened a DB console and ran &lt;code&gt;SET optimizer_trace=&amp;#39;enabled=on&amp;#39;;&lt;/code&gt; to enable
tracing. Then I ran the slow query and right after that I checked to see the
optimizer trace with
&lt;code&gt;SELECT * FROM information_schema.optimizer_trace LIMIT 1;&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The trace showed that the &lt;code&gt;PRIMARY&lt;/code&gt; index had a much lower execution cost (was
much faster) than &lt;code&gt;index_users_on_deleted_at&lt;/code&gt;, but it chose to use
&lt;code&gt;index_users_on_deleted_at&lt;/code&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;MariaDB, unlike PostgreSQL, doesn’t support conditional indexing. This means
that each index you add to a table indexes the whole table.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;In my case the &lt;code&gt;deleted_at&lt;/code&gt; column has around 4k different values in about 3
million rows. But a single value - &lt;code&gt;NULL&lt;/code&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;strong&gt;IN&lt;/strong&gt; clause - which is
extremely slow.&lt;/p&gt;
&lt;h3 id="how-do-i-make-the-query-fast"&gt;How do I make the query fast?&lt;/h3&gt;
&lt;p&gt;Luckily MariaDB, unlike PostgreSQL, provides an escape hatch for situations like
this. Through the &lt;strong&gt;USE INDEX&lt;/strong&gt; option, it allows you to specify the index it
should use when filtering.&lt;/p&gt;
&lt;p&gt;ActiveRecord doesn’t have a method for &lt;strong&gt;USE INDEX&lt;/strong&gt; but it can be added by
passing a string to the &lt;strong&gt;from&lt;/strong&gt; method.&lt;/p&gt;
&lt;p&gt;Telling the planner to use the &lt;code&gt;PRIMARY&lt;/code&gt; index speeds up the query drastically.
The execution time fell from 80 ms to 1 ms.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;users USE INDEX(PRIMARY)&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;where&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;97212&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2526&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 15027&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 11982&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10846&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 17091&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 19528&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 10731&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7785&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 23240&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 23238&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7244&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7221&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 6993&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 11959&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 16931&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 38234&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7006&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 8993&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 6766&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 39565&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 31681&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7088&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 7786&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 34029&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;explain&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&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;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&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;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# | id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra       |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# |  1 | SIMPLE      | users | range | PRIMARY       | PRIMARY | 8       | NULL | 25   | Using where |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# 1 row in set (0.00 sec)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But this doesn’t solve my problem. I can’t call the &lt;strong&gt;from&lt;/strong&gt; method in a preload
query - that query is built and executed by ActiveRecord internally.&lt;/p&gt;
&lt;p&gt;Though, now that I understand the problem I can trick the query planner into
doing what I want.&lt;/p&gt;
&lt;p&gt;If I replace &lt;code&gt;preload&lt;/code&gt; with &lt;code&gt;eager_load&lt;/code&gt; I can force the query planner to use at
least one &lt;code&gt;PRIMARY&lt;/code&gt; index and thereby speed things up considerably.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;EventLog&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;eager_load&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :device&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strict_loading&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;                .&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;explain&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   Device::EventLog Load (2.7ms)  ...&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# | id | select_type | table             | type   | possible_keys                       | key     | key_len | ref                                     | rows | Extra       |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# |  1 | SIMPLE      | device_event_logs | ALL    | NULL                                | NULL    | NULL    | NULL                                    | 25   | Using where |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# |  1 | SIMPLE      | users             | eq_ref | PRIMARY,index_users_on_deleted_at   | PRIMARY | 8       | development.device_event_logs.user_id   | 25   | Using where |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# |  1 | SIMPLE      | devices           | eq_ref | PRIMARY,index_devices_on_deleted_at | PRIMARY | 8       | development.device_event_logs.device_id | 25   | Using where |&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# +----+-------------+-------------------+--------+-------------------------------------+---------+---------+-----------------------------------------+------+-------------+&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And it worked! Both &lt;code&gt;PRIMARY&lt;/code&gt; indices were used when filtering, and the query
returns in 3 ms.&lt;/p&gt;
&lt;h3 id="why-did-this-work"&gt;Why did this work?&lt;/h3&gt;
&lt;p&gt;I had a hunch that a &lt;strong&gt;LEFT OUTER JOIN&lt;/strong&gt; would force the optimizer to use at
least one &lt;code&gt;PRIMARY&lt;/code&gt; index on the joined tables. Because &lt;code&gt;eager_load&lt;/code&gt; does the
same thing as &lt;code&gt;preload&lt;/code&gt;, in this case, but using a &lt;strong&gt;LEFT OUTER JOIN&lt;/strong&gt; it was
the perfect solution.&lt;/p&gt;
&lt;p&gt;And I was right. The query optimizer trace for the &lt;code&gt;eager_load&lt;/code&gt; query showed
that the optimizer tries to apply the same optimization as before, but its cost
is higher than using the &lt;code&gt;PRIMARY&lt;/code&gt; index.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Device::EventLog&lt;/code&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;code&gt;PRIMARY&lt;/code&gt;index goes way down compared to the optimization.&lt;/p&gt;
&lt;p&gt;At least that’s what I think is going on.&lt;/p&gt;
&lt;p&gt;Either way, sometimes replacing &lt;code&gt;preload&lt;/code&gt; with &lt;code&gt;eager_load&lt;/code&gt; can utilize indices
better and therefore speed up query execution.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Adding a reference from one table to another is straight forward in Rails.&lt;/p&gt;
&lt;p&gt;You create a migration using &lt;code&gt;rails generate&lt;/code&gt; and then write in it something
like &lt;code&gt;add_reference :device_event_logs, :door, foreign_key: true, null: true&lt;/code&gt;.
And then you run &lt;code&gt;rails migrate&lt;/code&gt;. Boom. Done!&lt;/p&gt;
&lt;p&gt;This is where my problems began.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I opened the DB console, entered &lt;code&gt;SELECT COUNT(1) FROM device_event_logs&lt;/code&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;/p&gt;
&lt;p&gt;Yikes. This means that adding the reference would take quite a while and use up
a decent chunk of storage space.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Yikes again. This meant that 5 min of downtime would cause 9000 failed
requests - potentially 9000 unhappy customers.&lt;/p&gt;
&lt;p&gt;I wanted to find a way to add this reference quickly, without locking the table
and without using too much storage space.&lt;/p&gt;
&lt;p&gt;Digging through the Rails documentation for &lt;code&gt;add_reference&lt;/code&gt; I didn’t find
anything that could help me solve this, so I resorted to adding the reference
manually, step by step.&lt;/p&gt;
&lt;p&gt;In Rails, a reference like
&lt;code&gt;add_reference :device_event_logs, :door, foreign_key: true, null: true&lt;/code&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;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# `add_reference :device_event_logs, :door, foreign_key: true, null: true` is equuivalent to&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_column &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device_event_logs&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :door_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device_event_types&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :door_id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_foreign_key &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device_event_types&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :doors&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Now I read through MariaDB’s documentation to see how I could optimize each of
these three steps.&lt;/p&gt;
&lt;p&gt;Turns out that adding the column is already optimized out of the box so I could
just add it through
&lt;code&gt;add_column :device_event_logs, :door_id, :integer, null: true&lt;/code&gt;. That would
create the column in less than a second and wouldn’t lock the table.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Sounds great, but as I have been burned before by documentation leading to
incorrect assumptions I decided to verify this.&lt;/p&gt;
&lt;p&gt;So I ran &lt;code&gt;CREATE INDEX foobar ON device_event_logs (door_id)&lt;/code&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;/p&gt;
&lt;p&gt;Good thing I checked.&lt;/p&gt;
&lt;p&gt;Then I tired specifying the algorithm explicitly with
&lt;code&gt;CREATE INDEX foobar ON device_event_logs (door_id) ALGORITHM inplace&lt;/code&gt; and it
erred out with a message saying that &lt;code&gt;INPLACE&lt;/code&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;code&gt;COPY&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Since the staging environment was setup after the production environment I’d
surely run into the same problem there. What now?&lt;/p&gt;
&lt;p&gt;After some more digging I found out that this problem can be solved by running
&lt;code&gt;OPTIMIZE TABLE device_event_logs&lt;/code&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;/p&gt;
&lt;p&gt;Now I could add the index with
&lt;code&gt;add_index :device_event_logs, :door_id, algorithm: :inplace&lt;/code&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;/p&gt;
&lt;p&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;code&gt;LOCK=NONE&lt;/code&gt; option. But
&lt;code&gt;add_foreign_key&lt;/code&gt; in rails accepts neither of these options, so I had to resort
to writing SQL.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;execute(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  &amp;lt;&amp;lt;~SQL&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    ALTER&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; TABLE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; device_event_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    ADD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; CONSTRAINT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fk_rails_53a1f7c81d&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      FOREIGN KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; IF&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; NOT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; EXISTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; (door_id)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      REFERENCES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; doors (id),&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      ALGORITHM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; INPLACE ,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      LOCK &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; NONE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;  SQL&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But when I tried to run the migration it failed, stating that an index on the
&lt;code&gt;doors&lt;/code&gt; table’s &lt;code&gt;id&lt;/code&gt; column was missing. What? How? It’s the primary key!&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I added an integer column to &lt;code&gt;device_event_logs&lt;/code&gt; thinking it would automatically
use &lt;code&gt;bigint&lt;/code&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;code&gt;add_column :device_event_logs, :door_id, :bigint, null: true&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;code&gt;disable_referential_integrity&lt;/code&gt;
block&lt;/a&gt;
after which the foreign key was added in less than a second.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;My migration looked like this in the end&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_column &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device_event_logs&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :door_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :bigint&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;add_index &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device_event_logs&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :door_id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; algorithm:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :inplace&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# https://api.rubyonrails.org/classes/ActiveRecord/Migration.html#method-i-reversible&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reversible &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;dir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  dir&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;up&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    disable_referential_integrity &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      execute(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;        &amp;lt;&amp;lt;~SQL&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          ALTER&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; TABLE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; device_event_logs&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;          ADD&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; CONSTRAINT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; fk_rails_53a1f7c81d&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;            FOREIGN KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; IF&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; NOT&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; EXISTS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; (door_id)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;            REFERENCES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; doors (id),&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;            ALGORITHM&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; INPLACE,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;            LOCK &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; NONE&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string)"&gt;        SQL&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The production migration took 15 min, caused no locks or downtime, and increased
the size of the database by a gigabyte.&lt;/p&gt;
&lt;p&gt;All in all, a big win and learning experience.&lt;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;This argument shows a deep misunderstanding of what microservices are and how
they work.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Services and APIs are the same thing as classes and methods.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The number of failure points is the same.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;So where does this single point of failure argument come from?&lt;/p&gt;
&lt;p&gt;I believe that Netflix and a simple case of not thinking with your own head are
the source of this odd argument.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;So if it works for Netflix why wouldn’t it work for us? Right?&lt;/p&gt;
&lt;p&gt;Let’s apply Chaos Monkey to our example with two services and see.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/having-a-monolith-is-a-single-point-of-failure-3cYdY3KZ7qHW/example_1.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1024; --attachment-height: 559; "&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" decoding="async" class="attachment__image" width="1024" height="559" src="https://media.stanko.io/originals/articles/having-a-monolith-is-a-single-point-of-failure-3cYdY3KZ7qHW/example_1.gif"&gt; &lt;/picture&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;p&gt;What gives? Maybe if we had two of every service it would be more resilient?&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/having-a-monolith-is-a-single-point-of-failure-3cYdY3KZ7qHW/example_2.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1024; --attachment-height: 559; "&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" decoding="async" class="attachment__image" width="1024" height="559" src="https://media.stanko.io/originals/articles/having-a-monolith-is-a-single-point-of-failure-3cYdY3KZ7qHW/example_2.gif"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;A simple login form is my go to example for explaining how nice ActiveModel is
to work with.&lt;/p&gt;
&lt;p&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# /app/models/login.rb&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Login&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveModel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Model&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ActiveModel&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Validations&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Callbacks&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :password&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validates &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; presence:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validates &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:password&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; presence:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validate &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:validate_user_exists!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validate &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:validate_password_for_user!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  before_validation &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; nil&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; validate_user_exists!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;present?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    errors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;add&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;not found&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; validate_password_for_user!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # `authenticate` comes from Rails&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # Reference:  https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;authenticate&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(password)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    errors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;add&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:password&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;is invalid&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @user &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find_by&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;username:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; username)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Which can then be used in a controller like any other model:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; LoginsController&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationController&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @login &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; create&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    permitted_params &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; params&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:login&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;permit&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:username&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :password&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @login &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(permitted_param)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;valid?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      session[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:current_user_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; @login&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      redirect_to root_path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; notice:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;Welcome back!&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      render &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; status:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :unprocessable_entity&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;You can use &lt;code&gt;form_for&lt;/code&gt; without extra arguments or configuration, &lt;strong&gt;I18n&lt;/strong&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;/p&gt;
&lt;p&gt;It looks and feels like you are working with an ActiveRecord model, which most
of us know and love.&lt;/p&gt;
&lt;p&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;strong&gt;valid?&lt;/strong&gt;, or you can check the result, and return a nice error message.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;WarehouseReservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApplicationModel&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_accessor&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :user&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validates &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; presence:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  validates &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; presence:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  after_initialize &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ||=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;creator&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; reserve!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; invalid?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    response &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; HTTP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;post&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;http://device.inventory.com/api/v1/register&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; data:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;ok?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      errors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;add&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; “failed to register”)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;warehouse_reference&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create_warehouse_reference&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;reservation_id:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;parse&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;body&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; payload&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;device_id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; reservation_holder:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; user&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;name&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;WarehouseReservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;user:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; User&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;all&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;sample&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;reserver!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;valid?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;reserver!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;warehouse_reference&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Device&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;WarehouseReservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;user:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; nil&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; device:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; nil&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;valid?&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;reserver!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;reservator&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;errors&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;full_messages&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; [&amp;quot;User missing&amp;quot;, &amp;quot;Device missing&amp;quot;]&lt;/span&gt;&lt;/span&gt;&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;code&gt;after_initialize_callback&lt;/code&gt; in &lt;code&gt;ApplicationModel&lt;/code&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;/p&gt;
&lt;p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;In contrast, effortless attention is… well… effortless.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;A proper 15 min break can keep you happy, focused and rested for hours.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/1200w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1200; --attachment-height: 1600; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAACwAQCdASoMABAABABoJYwAAlruRIjIAP6bwo7MCaGAxV8y4GDqY7gEe4MMYWx3FLwNo%2FWlo%2FrHyUMhrHgFyF4eUQAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #5c5b55;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/1200w.webp 1200w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The sun shining through the tree tops in a forest" loading="lazy" decoding="async" class="attachment__image" width="1200" height="1600" src="https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/take-a-break-4hwQBaUwEkZ9/image.jpeg/1200w.jpg 1200w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The sun shining through the tree tops in a forest&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;P.S. Many thanks to Marko I. for reviewing drafts of this article.&lt;/p&gt;
&lt;p&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;/p&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;
&lt;p&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.&lt;/p&gt;
&lt;p&gt;–Randall Munroe in the foreword to the Thing Explainer&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&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;/p&gt;
&lt;p&gt;My obsession with grouping things that way made me blind to what I was actually
doing - writing surprising code.&lt;/p&gt;
&lt;p&gt;When I think of the reasons why I did things that way only three things come to
mind.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Third, I didn’t want to have “fat” models or controllers because that has bitten
me in the past.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Treating most things as models, and making models heavier, has made the code I
write much less surprising.&lt;/p&gt;
&lt;p&gt;Having super skinny objects produces surprising code because you have to be very
verbose. Something boring like
&lt;code&gt;user.purchase(line_items).charge(authorization_code: params[:authorization_code])&lt;/code&gt;
becomes a convoluted mess like
&lt;code&gt;PurchaseChargerService(purchase: PurchaseBuilderService.new(line_items: line_items).call, form: purchase_form, user: current_user).call&lt;/code&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;By writing skinny objects you are pushing the complexity onto the programmer
instead of having it in code.&lt;/p&gt;
&lt;p&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&amp;#39;d get
rid of that one). I’m saying that you should make them as boring as possible.&lt;/p&gt;
&lt;p&gt;For all you know &lt;code&gt;user.purchase&lt;/code&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;/p&gt;
&lt;p&gt;How to make things boring?&lt;/p&gt;
&lt;p&gt;First, embrace the domain you are working with.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Instead of &lt;strong&gt;PurchaseChargerService&lt;/strong&gt; that lives in a service objects directory,
try naming it &lt;code&gt;Purchase::Charger&lt;/code&gt; and putting it under the Purchase model (e.g.
&lt;code&gt;app/model/purchase/charger.rb&lt;/code&gt;). You will know just by looking at the file that
it has something to do with purchases and charging them.&lt;/p&gt;
&lt;p&gt;Second, write code that mimics how people think about the domain you are working
in.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Third, extract actions into models to tell a story and form new domains.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;code&gt;Purchase::Charger&lt;/code&gt; model and delegate to it.&lt;/p&gt;
&lt;p&gt;Nobody will think less of you for writing boring code.&lt;/p&gt;
&lt;p&gt;Nobody will fuss about a method, module or class that is understandable but is
too pedestrian.&lt;/p&gt;
&lt;p&gt;Nobody wants to work with code that requires a manual to figure out how to do
the most basic actions.&lt;/p&gt;
&lt;p&gt;Keep it simple, keep it boring, and don’t surprise other.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update on 2022-08-27&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;After sharing this article with a few friends I got a wonderful question which I
want to address.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; If you put everything in &lt;code&gt;app/models&lt;/code&gt; isn’t that surprising too? People
will expect an ActiveRecord-like object and they can get anything.&lt;/p&gt;
&lt;p&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;code&gt;Purchase::Charger.new(purchase: Purchase.all.sample).save&lt;/code&gt; and hit
Enter out of the blue.&lt;/p&gt;
&lt;p&gt;If you see &lt;code&gt;Purchase::Charger&lt;/code&gt; you’d at least be curious why the model name
isn’t a noun - the name implies that it’s different.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I don&amp;#39;t see this as a realistic scenario, I expect people to know what they are
doing and if they don&amp;#39;t to read up until they do. In other words, I trust them
to do good work.&lt;/p&gt;
&lt;p&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;code&gt;app/models&lt;/code&gt;
inherit from &lt;code&gt;ActiveModel::Model&lt;/code&gt;. There are many reasons for this which I think
I’ll cover in a separate article.&lt;/p&gt;
&lt;p&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;/p&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;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;One day while reading about Control Theory and PID controllers, I realized that
Scrum was a time estimation tool.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;If Scrum is a PID controller what dimension does it control?&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Scrum isn’t a framework for people building the project, it’s a framework for
people concerned with timelines and roadmaps.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&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&amp;#39;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;/p&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;p&gt;Tom was brewing tea in preparation for a 5 o&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;5 o’clock rolls around, Tom joins the meeting from his home office and takes a
big sip of tea.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The managers woke up out of a sudden - they didn’t want to miss the drama.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;h3 id="monoliths-are-easier-to-maintain-for-smaller-teams"&gt;MONOLITHS ARE EASIER TO MAINTAIN FOR SMALLER TEAMS&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;And a monolith is great at ensuring that - one vision, one codebase and
everybody is aligned.&lt;/p&gt;
&lt;h3 id="monoliths-are-easier-to-scale-but-they-dont-scale-as-well-as-micro-services"&gt;MONOLITHS ARE EASIER TO SCALE, BUT THEY DON’T SCALE AS WELL AS MICRO-SERVICES&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But figuring out what the bottleneck is can be hard, both in monoliths and
micro-services.&lt;/p&gt;
&lt;h3 id="complexity-comes-from-business-not-code"&gt;COMPLEXITY COMES FROM BUSINESS NOT CODE&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;h3 id="micro-services-improving-encapsulation-or-separation-of-concerns-is-a-myth"&gt;MICRO-SERVICES IMPROVING ENCAPSULATION OR SEPARATION OF CONCERNS IS A MYTH&lt;/h3&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Look at Newton’s and Einstein&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif attachment--expandable" data-full-src="https://media.stanko.io/originals/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/strada_demo.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 450; --attachment-height: 800; "&gt;  &lt;img alt="Opening a share sheet in Hey" loading="lazy" decoding="async" class="attachment__image" width="450" height="800" src="https://media.stanko.io/originals/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/strada_demo.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Opening a share sheet in Hey&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png attachment--expandable" data-full-src="https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/852w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 852; --attachment-height: 1010; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2013%2016%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmwAAABXRUJQVlA4IGAAAAAQAgCdASoNABAABABoJbACdAEU9HhoRlZAAP7pTJ4mncNpkhXTWndrB37N8SUND%2FLDIzUwLH9uWi8F%2F1qQXEYNKX1dAiHW9fnEzokbTPoLeousRZ0DarR3d4hSBlSFeAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #707faa;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/480w.webp 480w, https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/800w.webp 800w, https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/852w.webp 852w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="852" height="1010" src="https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/852w.png" srcset="https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/480w.png 480w, https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/800w.png 800w, https://media.stanko.io/variants/articles/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs/2022-06-13_15-11_3.png/852w.png 852w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&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;/p&gt;
&lt;p&gt;Inspecting it showed that it was controlled by a &lt;code&gt;bridge/share&lt;/code&gt; Stimulus
controller. So I opened that controller and saw that it only had a &lt;code&gt;component&lt;/code&gt;
static variable and a &lt;strong&gt;#share&lt;/strong&gt; method. The static variable was hard-coded to
the value “share”. The &lt;strong&gt;#share&lt;/strong&gt; method just passed the current URL to a
&lt;strong&gt;#send&lt;/strong&gt; method it inherited from &lt;code&gt;bridge/base_component_controller&lt;/code&gt;.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// bridge/share.js&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;import&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; BaseComponentController &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;from&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;bridge/base_component_controller&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ShareController&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; extends&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; BaseComponentController&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    static&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; component &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;share&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    share&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.preventDefalt&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;share&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; window&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.location })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I followed the code and opened the &lt;code&gt;bridge/base_component_controller&lt;/code&gt;. Its
&lt;strong&gt;#send&lt;/strong&gt; method took an event name, a data object, and a callback function. It
built a &lt;code&gt;message&lt;/code&gt; object with the event name, data, callback and the
controller’s component. Then it forwarded everything to the
&lt;code&gt;window.webBridge.send&lt;/code&gt; method and stored the returned value as the ID of the
message.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// bridge/base_component_controller.js&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Pseudocode for BaseComponentController&amp;#39;s send method&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; callback) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; message &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { component&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;constructor&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.component&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; callback }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; messageId &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; window&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;webBridge&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;But where did &lt;code&gt;window.webBridge&lt;/code&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;/p&gt;
&lt;p&gt;This got me excited! I started reading the code of the class to figure out what
it does.&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// strata.js&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Pseudocode for Strata&amp;#39;s send method&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(originalMessage) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;!&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.supportsComponent&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.component)) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.generateID&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; message &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        component&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; originalMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.component&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; originalMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; originalMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.data &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;adapter&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.receive&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;originalMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.callback) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.callbacks[id] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; originalMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.callback&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;receive&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; callback&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.callbacks[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.id]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (callback) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;callback&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.data)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;supportsComponent&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(component) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; this&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;adapter&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.supportsComponent&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(component)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;On load, it initialized itself and was assigned to &lt;code&gt;window.webBridge&lt;/code&gt;. Then it
fired a &lt;strong&gt;web-bridge:ready&lt;/strong&gt; on the document.&lt;/p&gt;
&lt;p&gt;It had an &lt;code&gt;adapter&lt;/code&gt; variable which was called in most of its methods.&lt;/p&gt;
&lt;p&gt;Its &lt;strong&gt;#send&lt;/strong&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;/p&gt;
&lt;p&gt;There was also a &lt;strong&gt;#receive&lt;/strong&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;/p&gt;
&lt;p&gt;Then there was the &lt;strong&gt;spportsComponent&lt;/strong&gt; method that queried the adapter for
components it could support. Probably to avoid sending messages that the adapter
couldn&amp;#39;t process.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;code&gt;window.external&lt;/code&gt; object (on iOS it’s &lt;code&gt;window.webkit&lt;/code&gt;). The
app can expose functions on that object that when called from JS invoke a
handler function in the app.&lt;/p&gt;
&lt;p&gt;So, the app probably registered an adapter by injecting JS into the browser.
That would explain why &lt;strong&gt;web-bridge:ready&lt;/strong&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 class="highlight" data-language="swift"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// AppDelegate.swift&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Example of how you could register an adapter with Strada from iOS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; supportedComponents &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;share&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; supportedComponentsJSON &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; String&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; JSONSerialization.data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        withJSONObject&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; supportedComponents,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        options&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    )&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    encoding&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; String&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.Encoding.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;utf8&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; registerAdapterJS &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;window.registerStradaAdapter = () =&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  const supportedComponents = &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;\(supportedComponentsJSON)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  window.webBridge.setAdapter({&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;        platform: &amp;quot;ios&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;        supportsComponent: (name) =&amp;gt; return supportedComponents.include(name),&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    supportedComponents: supportedComponents,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;        receive: (message) =&amp;gt; window.webkit.messageHandlers.strada.receive(message))&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;if (window.webBridge) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  window.registerStradaAdapter()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;else {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  document.addEventListener(&amp;quot;web-bridge:ready&amp;quot;, window.registerStradaAdapter)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Inject the script right after the document is loaded&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;webView.configuration.userContentController.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;addUserScript&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    WKUserScript&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      source&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; registerAdapterJS,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    injectionTime&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; WKUserScriptInjectionTime.AtDocumentEnd,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    forMainFrameOnly&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;  )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&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 class="highlight" data-language="swift"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// AppDelegate.swift&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Example of how an iOS app could add a&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// `window.webkit.messageHandlers.strada.receive`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// function and process calls to it.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Somewhere in AppDelegate after you have created your&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// browser view (webView) and have initialized Turbo Native&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; strada &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Strada&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; contentController &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; webView.configuration.userContentController&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;contentController.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;addScriptMessageHandler&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;strada, name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;strada&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Strada.swift&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Example message handler that responds to calls to&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// `window.webkit.messageHandlers.strada.receive`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Strada&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; WKScriptMessageHandler &lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    func&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; userContentController&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;userContentController&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: WKUserContentController&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;                                                         didReceiveScriptMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt; message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;: WKScriptMessage&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;!&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;        // The `name` attribute holds the name of the function that was invoked&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;message.name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;!=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;receive&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        return&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;message.body&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;component&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;share&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        message.body&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;event&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; ==&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;share&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;      // show share sheet&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Strada.swift&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Example of how you could call the adapter from iOS&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; message: [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;String&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Any&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;7&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;  &amp;quot;data&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;foo&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;bar&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;let&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; messageJSON &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; String&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; JSONSerialization.data&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        withJSONObject&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; message,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        options&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;    )&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    encoding&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; String&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.Encoding.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;utf8&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;webView.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;evaluateJavaScript&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;    &amp;quot;window.webBridge.receive(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;\(messageJSON)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;)&amp;quot;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;    completionHandler&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; nil&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;I realized that Strada was a bridge between the native app and the frontend.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The mystery of the share sheet was solved. But now my head was buzzing with
possibilities.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;But with Strada, any existing web app can become a native app.&lt;/p&gt;
&lt;p&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: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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1279w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1279; --attachment-height: 751; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAADwAQCdASoQAAkABABoJbACdAEXXTo5ugAA%2BpjxHBOMnBovy0Dm%2FWbCCCZtKfHRBG8CjQJxYjTP9K6FmB0mHKwAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #869863;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/480w.webp 480w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/800w.webp 800w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1279w.webp 1279w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A walkway at Plitvice Lakes, Croatia" loading="lazy" decoding="async" class="attachment__image" width="1279" height="751" src="https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1200w.png" srcset="https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/480w.png 480w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/800w.png 800w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1200w.png 1200w, https://media.stanko.io/variants/articles/the-judo-way-np6Ftd4sUGzK/lake_walkway.png/1279w.png 1279w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A walkway at Plitvice Lakes, Croatia&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The story was about a small tree in a strong wind. The strong wind could uproot
big trees but couldn&amp;#39;t do the same to small trees. How so?&lt;/p&gt;
&lt;p&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&amp;#39;s full force on
them. But the big trees aren&amp;#39;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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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 &amp;quot;force&amp;quot; with anything
else, and to accept that an advantageous position might not be exactly what you
want but a step in that direction.&lt;/p&gt;
&lt;p&gt;In the case of the story, if your goal is to stay in the ground then it&amp;#39;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;/p&gt;
&lt;p&gt;I&amp;#39;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;/p&gt;
&lt;p&gt;For example, at work we use the Scrum methodology and it&amp;#39;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&amp;#39;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;/p&gt;
&lt;p&gt;We can&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The problem is that management often doesn&amp;#39;t know, and can&amp;#39;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&amp;#39;t see those problems, don&amp;#39;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;/p&gt;
&lt;p&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;/p&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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1203w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1203; --attachment-height: 502; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkQAAABXRUJQVlA4IDgAAADQAQCdASoQAAcABABoJaACdADx9yArgAD%2B05VTH0kopvdQruODmPEcnqtJYqf5iAQv7tE%2FW0iAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #bba9d7;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/480w.webp 480w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/800w.webp 800w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1203w.webp 1203w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Abstract color pour by Pawel Czerwinski" loading="lazy" decoding="async" class="attachment__image" width="1203" height="502" src="https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1200w.png" srcset="https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/480w.png 480w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/800w.png 800w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1200w.png 1200w, https://media.stanko.io/variants/articles/new-coat-of-paint-3tgme2PMzsya/paint.png/1203w.png 1203w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Abstract color pour by Pawel Czerwinski&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;I started this blog back in 2016 as a way to share and discuss ideas and
solutions to problem&amp;#39;s I&amp;#39;ve tackled as part of my work, life, education and
hobbies.&lt;/p&gt;
&lt;p&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&amp;#39;t
have much money at the time, didn&amp;#39;t want to build my own blog and didn&amp;#39;t want to
self-host or pay for WordPress.&lt;/p&gt;
&lt;p&gt;The first article here &lt;a href="https://stanko.io/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;/p&gt;
&lt;p&gt;Then &lt;a href="do-you-really-need-websockets-343aed40aa9b"&gt;&amp;quot;Do you really need
WebSockets&amp;quot;&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;/p&gt;
&lt;p&gt;Chasing that high made me imitate my previous success. All articles after &amp;quot;Do
you really need WebSockets&amp;quot; were in-depth comparisons. But writing those
comparisons wasn&amp;#39;t fun, it felt like a chore. Even though I was satisfying my
own curiosity I didn&amp;#39;t enjoy the process of writing down the conclusion. Why?
Because I imposed the structure and tone of &amp;quot;Do you really need WebSockets&amp;quot; on
those articles to make them more likely to succeed (at least in my mind).&lt;/p&gt;
&lt;p&gt;The problem with &amp;quot;Do you really need WebSockets&amp;quot; is that it&amp;#39;s written like an
academic paper - it&amp;#39;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;/p&gt;
&lt;p&gt;The first article I ever wrote in this blog represents me better than the last
five.&lt;/p&gt;
&lt;p&gt;Once I realized that I started working on this iteration of the blog.&lt;/p&gt;
&lt;p&gt;The first step for me was to get away from Medium as the site had many features
I didn&amp;#39;t want my blog to have.&lt;/p&gt;
&lt;p&gt;The feature I wanted to get rid off the most was &amp;quot;claps&amp;quot; (Medium&amp;#39;s version of
likes). Comparing something by how many likes it has produces feedback loops
like the one I got into after &amp;quot;Do you really need WebSockets&amp;quot;. And the number of
likes doesn&amp;#39;t tell me anything of value - I don&amp;#39;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&amp;#39;t know why.&lt;/p&gt;
&lt;p&gt;The second feature I didn&amp;#39;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&amp;#39;t read the article and made ungrounded claims
calling me out as a liar and dimwit.&lt;/p&gt;
&lt;p&gt;At first I tried to be kind, explain their claims as false and correct them. But
that took more time than I&amp;#39;d like to invest into responding to comments so I
tried to ignore those people - this didn&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;A custom design that strains the eyes less and that has no accessibility issues.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/new_design.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1252; --attachment-height: 787; "&gt;  &lt;img alt="The new design of stanko.io" loading="lazy" decoding="async" class="attachment__image" width="1252" height="787" src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/new_design.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The new design of stanko.io&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Link previews - because having to open links to see if you want to read what
they link to is annoying&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/link_previews.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1252; --attachment-height: 787; "&gt;  &lt;img alt="Link previews" loading="lazy" decoding="async" class="attachment__image" width="1252" height="787" src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/link_previews.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Link previews&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;My own analytics - because that way I can respect my readers&amp;#39; privacy and track
backlinks to articles&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/backlinks_and_analytics.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 2442; --attachment-height: 750; "&gt;  &lt;img alt="Backlinks &amp;amp; analytics on stanko.io" loading="lazy" decoding="async" class="attachment__image" width="2442" height="750" src="https://media.stanko.io/originals/articles/new-coat-of-paint-3tgme2PMzsya/backlinks_and_analytics.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Backlinks &amp;amp; analytics on stanko.io&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Visible RSS and ATOM feed for subscriptions&lt;/p&gt;
&lt;p&gt;It&amp;#39;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&amp;#39;m having fun again.&lt;/p&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: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;figure class="attachment attachment--preview attachment--jpg" data-full-src="https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 3456; --attachment-height: 1486; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRk4AAABXRUJQVlA4IEIAAADwAQCdASoQAAcABABoJbACdAENhqukWAAA%2Fuq57%2BFSc78%2FBENt9W7V%2BUUe6v6UryiMf%2BJ3dn3PjIjgtK%2FguVvgAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8da6a5;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/480w.webp 480w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/800w.webp 800w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/1200w.webp 1200w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/1600w.webp 1600w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The GraphQL logo overlayed over a Japanese shrine" loading="lazy" decoding="async" class="attachment__image" width="3456" height="1486" src="https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/480w.jpg 480w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/800w.jpg 800w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/graphql-file-upload-with-shrine-45fa26463c68/9719cecb0e0383439f4d62f48bc1b6f8.jpg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The GraphQL logo overlayed over a Japanese shrine&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;s still
useful for other libraries, but the code examples will only apply to Shrine.&lt;/p&gt;
&lt;h2 id="direct-file-upload"&gt;Direct file upload&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Handling uploads is a resource intensive task for the server&lt;/strong&gt; (using up IO
and bandwidth). To resolve this, &lt;strong&gt;we can upload the file directly from the
client to our block-storage server&lt;/strong&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;/p&gt;
&lt;p&gt;This is in contrast to the classic approach where we would first upload the
client&amp;#39;s file to our server, and then from our server to the block-storage
server.&lt;/p&gt;
&lt;p&gt;The benefit is that &lt;strong&gt;our server doesn&amp;#39;t have to process the file&lt;/strong&gt; (which uses
up IO) and &lt;strong&gt;it doesn&amp;#39;t have to upload the file&lt;/strong&gt; to the block-storage server
(more IO usage). It also solves scaling issues related to distributed file
caches and distributed resource management.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The downside to this method is apparent when the uploaded file needs to be
processed&lt;/strong&gt;. To process a file our server needs to get the client&amp;#39;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&amp;#39;s
necessary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This method should be satisfactory for most use-cases&lt;/strong&gt;. If your application
doesn&amp;#39;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;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/shrinerb/shrine/blob/3c14113ce2fb77d1f3e96bd4e4ff10416651fc7c/doc/direct_s3.md"&gt;&lt;strong&gt;Shrine supports this feature
out-of-the-box&lt;/strong&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&amp;#39;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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This code is license under the MIT license&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Full text: https://opensource.org/licenses/MIT&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Author Stanko K.R. &lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Usage:&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ```ruby&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# user.avatar = S3UrlToShrineObjectConverter.call(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   arguments[:input][:avatar][:url],&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   name: arguments[:input][:avatar][:filename]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# )&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;uri&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; S3UrlToShrineObjectConverter&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; self.call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;args&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;args)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; initialize&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;url&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;nil&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @url &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    return&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; unless&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; object&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;exists?&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      id:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      storage:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; storage&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      metadata:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        size:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; object&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;content_length&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        filename:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; id&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        mime_type:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; object&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;content_type&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  protected&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  private&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; object&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @object &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; bucket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;object&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(uri&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;path&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; uri&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @uri &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; URI&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(url)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; bucket&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @bucket &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; begin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      s3 &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; AWS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;S3&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;({}) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# TODO: configure this&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      s3&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;buckets&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;your_bucket_name&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# TODO: load this from the app&amp;#39;s config&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @id &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; uri&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;split&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;last&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; storage&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @storage &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; uri&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;path&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;split&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;first&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&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 class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;mutation SetAvatar($url: String, $filename: String) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  updateUser(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    id: &amp;quot;some_user_uuid&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    input: {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      avatar: {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;        filename: $filename&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;        url: $url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  ) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    avatar_url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The server uses this reference to build a Shrine object and attach it.&lt;/p&gt;
&lt;p&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 &amp;quot;anyone” and &amp;quot;anytime” issues —
we have control over who can request presigned URLs to upload or download files.&lt;/p&gt;
&lt;h2 id="base64-encoding"&gt;Base64 encoding&lt;/h2&gt;
&lt;p&gt;This method is &lt;strong&gt;the simplest, yet it&amp;#39;s the worst&lt;/strong&gt; for real-world applications.&lt;/p&gt;
&lt;p&gt;Your client can encode the file&amp;#39;s data as Base64 and set it as the value of a
mutation&amp;#39;s field.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;mutation SetAvatar($data: String, $filename: String) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  updateUser(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    id: &amp;quot;some_user_uuid&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    input: {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      avatar: {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;        filename: $filename&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;        data: $data&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  ) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    avatar_url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&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 class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#!/usr/bin/ruby&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;user&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.avatar_data_uri &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; arguments[:input][:avatar][:data]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;user&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.save&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The biggest issue is memory consumption&lt;/strong&gt;. Since the image is now part of your
request&amp;#39;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&amp;#39;s at least 50MiB in memory. &lt;strong&gt;Storing whole files in memory can and
will crash your application&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I advise against using this method&lt;/strong&gt;. If you wish to implement it, you can do
so using Shrine&amp;#39;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;code&gt;&amp;lt;field&amp;gt;_data_uri&lt;/code&gt; of your file in the resolver.&lt;/p&gt;
&lt;p&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 id="multipart-upload"&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;strong&gt;Since GraphQL is just
a JSON request we can pass files alongside our request&lt;/strong&gt;, but we need logic to
interpret them.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ll be referencing &lt;a href="https://github.com/jaydenseric"&gt;jaydenseric&lt;/a&gt;&amp;#39;s &lt;a href="https://github.com/jaydenseric/graphql-multipart-request-spec"&gt;multipart
request
specification&lt;/a&gt; as
it&amp;#39;s supported by several client and server implementations at this moment.&lt;/p&gt;
&lt;pre class="highlight" data-language="plain"&gt;&lt;span class="line"&gt;&lt;span&gt;mutation UploadGalleryImages {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  uploadGalleryImages(&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    galleryID: &amp;quot;some_gallery_id&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    images: [null, null]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  ) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    id&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    filename&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;    url&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;A typical GraphQL request consists of three fields &lt;strong&gt;query&lt;/strong&gt;, &lt;strong&gt;variables&lt;/strong&gt; and
&lt;strong&gt;operationName&lt;/strong&gt;. This spec adds a fourth — &lt;code&gt;map&lt;/code&gt;. The map contains indices and
JSON pointers, each index represents an image and each pointer represents the
location of the &lt;code&gt;ActionDispatch::Http::UploadedFile&lt;/code&gt; that holds the
corresponding image.&lt;/p&gt;
&lt;p&gt;Say we want to upload two images to a gallery using this method, our GraphQL
mutation might look like this:&lt;/p&gt;
&lt;p&gt;Since the server knows that the &lt;code&gt;images&lt;/code&gt; field is an array of &lt;strong&gt;File&lt;/strong&gt; types it
will match all &lt;code&gt;null&lt;/code&gt; values in the array, in order of appearance, with their
index in the &lt;code&gt;map&lt;/code&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   galleryID: &amp;quot;some_gallery_id&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   images: [&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#     #&amp;lt;:http::uploadedfile:0x000055fb90c6dbd0&amp;gt;, @original_filename=&amp;quot;b.txt&amp;quot;, @content_type=&amp;quot;text/plain&amp;quot;, @headers=&amp;quot;Content-Disposition: form-data; name=\&amp;quot;0\&amp;quot;; filename=\&amp;quot;b.txt\&amp;quot;\r\nContent-Type: text/plain\r\n&amp;quot;&amp;gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#     #&amp;lt;:http::uploadedfile:0x000055fb90c6da18&amp;gt;, @original_filename=&amp;quot;c.txt&amp;quot;, @content_type=&amp;quot;text/plain&amp;quot;, @headers=&amp;quot;Content-Disposition: form-data; name=\&amp;quot;1\&amp;quot;; filename=\&amp;quot;c.txt\&amp;quot;\r\nContent-Type: text/plain\r\n&amp;quot;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;#   ]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;gallery &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Gallery&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;find&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(arguemnts[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:gallery_id&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;arguments[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:images&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Image has a Shrine uploader attached to `file_data`&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Image&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;create&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;file:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; file&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; gallery:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; gallery)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;From here on you can assign those &lt;strong&gt;UploadedFile&lt;/strong&gt; objects to any of your own
objects as usual. No Shrine plugins needed.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;My suggestion is to use the direct upload method&lt;/strong&gt; if possible. It&amp;#39;s the only
method that doesn&amp;#39;t require modifications to the GraphQL protocol, it&amp;#39;s well
understood, and it&amp;#39;s used outside of the GraphQL ecosystem (better
interoperability with client libraries).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If direct uploads won&amp;#39;t work for you I would take a gamble on multipart
uploads.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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&amp;#39;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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/2250w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2250; --attachment-height: 750; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%205%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAABwAQCdASoQAAUABABoJaACdAFAAAD%2B7hGVRkITNV%2BnI6etF4L%2FcH6g%2FiDEAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #9c1624;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/480w.webp 480w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/800w.webp 800w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/2250w.webp 2250w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Ruby's new function composition syntax" loading="lazy" decoding="async" class="attachment__image" width="2250" height="750" src="https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/1200w.png" srcset="https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/480w.png 480w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/800w.png 800w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/1200w.png 1200w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/1600w.png 1600w, https://media.stanko.io/variants/articles/function-composition-ruby-8f91aea21e5f/1den4tfex_0ccqbh8iq1mqg.png/2250w.png 2250w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Ruby&amp;#39;s new function composition syntax&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Last week &lt;code&gt;Proc#&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;Proc#&amp;gt;&amp;gt;&lt;/code&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;h2 id="composition-vs-inheritance"&gt;Composition vs. inheritance&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Ruby is an object-oriented language&lt;/strong&gt; (among others), meaning it has the
concept of an object and the class of an objects — which has the concept of
inheritance.&lt;/p&gt;
&lt;p&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; 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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Wrapper around a HTTP library&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; ...&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Knows how to decode JSON responses from the API&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; JSONApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; ...&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Knows how to decode XML responses from the API&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; XMLApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; ...&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# Exposes an API&amp;#39;s endpoints as methods on an object&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MyAppApiClient&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :json_client&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :xml_client&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; initialize&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;api_token&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @json_client &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JSONApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(api_token)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @xml_client &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; XMLApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(api_token)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; current_account&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    json_client&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/api/current_account&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; balance&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    xml_client&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/api/current_account/balance&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; MyAppApiClient&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :json_client&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  attr_reader&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :xml_client&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; initialize&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;api_token&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @json_client &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;API_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:parse&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_proc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    @xml_client &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;API_KEY&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; XML&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:parse&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_proc&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; current_account&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    json_client&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/api/current_account&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; balance&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    xml_client&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/api/current_account/balance&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;array &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;3&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;4&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;5&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;array&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt; # =&amp;gt; #&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;array&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;i&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; i &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; } &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [2, 4, 6, 8, 10]&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This certainly would get the job done. But &lt;strong&gt;this code has two issues&lt;/strong&gt; that we
need to address.&lt;/p&gt;
&lt;p&gt;First, both &lt;strong&gt;JSONApiClient&lt;/strong&gt; and &lt;strong&gt;XMLApiClient&lt;/strong&gt; are &lt;strong&gt;tightly coupled to
their parent and provide little functionality besides the functionality they
inherited&lt;/strong&gt; — meaning that any change to the parent class would probably break
the classes inheriting from it.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;JSONApiClient&lt;/strong&gt; and &lt;strong&gt;XMLApiClient&lt;/strong&gt; are &lt;strong&gt;too specialized&lt;/strong&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;/p&gt;
&lt;p&gt;&lt;strong&gt;We can address both issues by passing the desired parser as an argument to
ApiClient&lt;/strong&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;code&gt;ApiClient&lt;/code&gt; that can parse any kind of data
without needing to subclass it.&lt;/p&gt;
&lt;p&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; This is the basic idea behind &lt;a href="https://en.wikipedia.org/wiki/Function_composition_%28computer_science%29"&gt;function
composition&lt;/a&gt;.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;We use composition every day without even noticing it.&lt;/strong&gt; &lt;strong&gt;Enumerable#map&lt;/strong&gt;
uses composition to enable us to transform arrays.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;map&lt;/code&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 id="proc-and-proc"&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;code&gt;Proc#&amp;gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;Proc#&amp;lt;&amp;lt;&lt;/code&gt; were introduced in Ruby 2.6. &lt;strong&gt;They provide a
substantial quality-of-life improvement when it comes to composing functions.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Proc#&amp;gt;&amp;gt;&lt;/code&gt; is similar to Elixir’s pipeline, but instead of returning a result it
returns a proc — a callable object.&lt;/p&gt;
&lt;p&gt;In mathematical terms, &lt;code&gt;f(x) &amp;gt;&amp;gt; g(x)&lt;/code&gt; is the same as &lt;code&gt;g(f(x))&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Let’s go back to our &lt;code&gt;ApiClient&lt;/code&gt; example:&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;f &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; x { x &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;g &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; x { x &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# h is the composition of f and g&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;h &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; f &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; g&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# h is the same as -&amp;gt; x { (x + 2) * 2 }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;h) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [6, 8, 10]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This is exactly the same as&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;f)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;g) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [6, 8, 10]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;fetch_transactions &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  ApiClient&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(api_token)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:parse&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;-&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; response { response[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;data&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;][&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;transactions&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] })&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;fetch_transaction&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;/api/current_user/transactions&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;f &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; x { x &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;+&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;g &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; x { x &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;*&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# h is the composition of g and f&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;h &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; f &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; g&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# h is the same as -&amp;gt; x { (x * 2) + 2 }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;h) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [4, 6, 8]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# This is exactly the same as&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;1&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 2&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; 3&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;g)&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;map&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;f) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; [4, 6, 8]&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;Notice that we didn’t pass anything to &lt;code&gt;ApiClient&lt;/code&gt;, we didn’t need to do
dependency injection. &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; The above
example creates a function that returns all transaction from an API endpoint.&lt;/p&gt;
&lt;p&gt;Note, if you want Elixir style pipelines that return a result check out
&lt;code&gt;yield_self&lt;/code&gt; or the new alias for it &lt;strong&gt;then&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;On the other hand, &lt;code&gt;Proc#&amp;lt;&amp;lt;&lt;/code&gt; &lt;strong&gt;is more in-line with Haskell style composition:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Or, in mathematical terms, &lt;code&gt;f(x) &amp;lt;&amp;lt; g(x)&lt;/code&gt; is the same as &lt;code&gt;f(g(x))&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Both &lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; are basically the same, &lt;strong&gt;which one to use is only subject to
your preference.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Function composition is very useful in languages that don’t have the concept
of classes and inheritance&lt;/strong&gt; as it enables you to “inherit” the behavior of a
function and extend/specialize its behavior. Like in the following example.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Since we do have inheritance in Ruby this kind of composition is less
useful.&lt;/strong&gt; Yet it gives us the ability to create utility functions on-the-fly
thus &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;a href="https://en.wikipedia.org/wiki/Composition_over_inheritance"&gt;&lt;strong&gt;composition-over-inheritance&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;.&lt;/strong&gt;
In my opinion, this is a big step forward for the language.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;As its implemented now, composition sticks out like a sore thumb. &lt;strong&gt;The
ecosystem needs to grow to accommodate for this feature.&lt;/strong&gt; Constantly calling
&lt;strong&gt;#method&lt;/strong&gt; on a class/module is confusing and distracting — a short hand
operator for this purpose would be welcome (I would propose &lt;code&gt;&amp;amp;[Json].parse&lt;/code&gt;).
The &lt;code&gt;map&lt;/code&gt;, &lt;strong&gt;reduce&lt;/strong&gt; and other enumerator methods are hard to compose since
they are implemented on an instance of an object — an &lt;strong&gt;Enum&lt;/strong&gt; module exposing
them would be nice.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;net/http&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;uri&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;json&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;fetch &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:URI&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Net&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;HTTP&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:get&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JSON&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:parse&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; response { response&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;dig&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;bpi&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;EUR&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;rate&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;||&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;0&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; } \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; -&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; value { value&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;gsub&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;,&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) } \&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  &amp;gt;&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; self&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;method&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:Float&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;fetch&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;https://api.coindesk.com/v1/bpi/currentprice.json&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# =&amp;gt; 3530.6782&lt;/span&gt;&lt;/span&gt;&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. inheritance

Ruby 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;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/1080w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1080; --attachment-height: 517; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlwAAABXRUJQVlA4IFAAAAAQAgCdASoQAAgABABoJbACdAEC1oYudv2QAP5oabcakylH4JcPR0yE2ll7k3C6pMHork%2BA9h73hfR1zMJ5WU%2BRqPXNfiNRMnuzY%2BvcJAAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #9e684c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/1080w.webp 1080w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Hand holding a plastic Ruby gemstone" loading="lazy" decoding="async" class="attachment__image" width="1080" height="517" src="https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/1080w.jpg" srcset="https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/best-image-uploader-for-rails-revisited-3b3b7618cc4c/ruby.jpeg/1080w.jpg 1080w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Hand holding a plastic Ruby gemstone&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;s time to revisit this topic.&lt;/p&gt;
&lt;h2 id="why-do-we-use-uploader-gems"&gt;Why do we use uploader gems?&lt;/h2&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;After we get the file we have multiple options what to do with it:&lt;/p&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&amp;#39;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&amp;#39;s
S3&lt;/a&gt; or &lt;a href="https://www.digitalocean.com/products/spaces/"&gt;DigitalOcean&amp;#39;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&amp;#39;t know what kind of processing our
client&amp;#39;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&amp;#39;s display. The image
will then be processed to the desired dimensions and passed to the device.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&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;/p&gt;
&lt;h2 id="activestorage--shrine"&gt;ActiveStorage &amp;amp; Shrine&lt;/h2&gt;
&lt;p&gt;Since I published the previous article two new “mainstream” libraries have
appeared. &lt;a href="http://rubyonrails.org/"&gt;Rails&amp;#39;&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&amp;#39;s Shrine&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="activestorage"&gt;ActiveStorage&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;s URL&lt;/strong&gt; — which enables it to sanitize and
rate-limit clients&amp;#39; 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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;s way.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;The biggest issue I&amp;#39;ve experienced is that &lt;strong&gt;it&amp;#39;s coupled to ActiveRecord.&lt;/strong&gt;
This forces developers&amp;#39; choice of ORM, and makes potential library migrations
more difficult. Today it&amp;#39;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;/p&gt;
&lt;p&gt;&lt;strong&gt;I&amp;#39;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&amp;#39; file
uploading and processing needs. With it, you can add file upload to your Rails
app in minutes.&lt;/p&gt;
&lt;h2 id="shrine"&gt;Shrine&lt;/h2&gt;
&lt;p&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&amp;#39;ll try to be as objective as
possible.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Shrine takes CarrierWave&amp;#39;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&amp;#39;s / Sequel&amp;#39;s plugin
system&lt;/a&gt; and cleaning up
the API.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Of course, &lt;strong&gt;there&amp;#39;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;/p&gt;
&lt;p&gt;&lt;strong&gt;Different file storage servers, and caching is supported out-of-the-box.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Something that I&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;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;/p&gt;
&lt;p&gt;From my experience, people complain that they have to configure Shrine for
Rails. I&amp;#39;ve never found that to be an issue, but to address those people I&amp;#39;d
hope the author creates a wrapper gem to ease the integration with sane defaults
for most configuration options.&lt;/p&gt;
&lt;h2 id="paperclip"&gt;Paperclip&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&gt;Paperclip didn&amp;#39;t improve much since the last round-up, but it didn&amp;#39;t need to. It
always was the simplest and quickest solution, and intended only to attach files
and light processing.&lt;/p&gt;
&lt;p&gt;Much of what it does, ActiveStorage does better and it&amp;#39;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&amp;#39;t compete with Rail&amp;#39;s own
solution.&lt;/p&gt;
&lt;h2 id="carrierwave-refile--dragonfly"&gt;&lt;strong&gt;CarrierWave, Refile &amp;amp; Dragonfly&lt;/strong&gt;&lt;/h2&gt;
&lt;p&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&amp;#39;t
used either of them in quite the while as ActiveStorage and Shrine completely
substituted them in my tool belt.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Those libraries haven&amp;#39;t changed much in the last two years.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;d guess that this is enough for
90% of the projects that exist.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;CarrierWave, Refile and Dragonfly are by no means dead.&lt;/strong&gt; They do have their
audiances, but I&amp;#39;ve found that ActiveStorage or Shrine can do most if not more
than those libraries can, so I&amp;#39;d recommend those two over the others for new
projects.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you aren&amp;#39;t using Rails&lt;/strong&gt;, then there is only one real option for you —
Shrine.&lt;/p&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...</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;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4000; --attachment-height: 1734; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAACwAQCdASoQAAcABABoJQBOgBgtK2EgAP7STKxr%2B2%2B3UbrPdZ68U%2FArdox%2BoJsp%2FKgkbBF16HQ7D83OyFuNuiDONoCAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #6d6458;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="My messy desk" loading="lazy" decoding="async" class="attachment__image" width="4000" height="1734" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/oqbl7rd0y9yfn92dskv1uw.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;My messy desk&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Recently I&amp;#39;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;/p&gt;
&lt;p&gt;Note: I&amp;#39;m by no means a legal expert. Everything written here is what I&amp;#39;ve found
while researching the issue. Some things may not apply to the region you live
in.&lt;/p&gt;
&lt;h2 id="why-is-licensing-important"&gt;Why is licensing important?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Unless you provide a license with your code, the code (legally) can&amp;#39;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;/p&gt;
&lt;p&gt;I wasn&amp;#39;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;/p&gt;
&lt;p&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 &amp;quot;&lt;a href="https://blog.codinghorror.com/pick-a-license-any-license/"&gt;Pick a License, Any
License&lt;/a&gt;&amp;quot;.&lt;/p&gt;
&lt;p&gt;For that reason you&amp;#39;ll often hear something along the lines of &lt;em&gt;&amp;quot;Experienced
developers won&amp;#39;t touch unlicensed code because they have no legal right to use
it.&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="how-to-choose-a-license-for-your-project"&gt;How to choose a license for your project?&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;ve seen at least 20 different software
licenses over the years, and I didn&amp;#39;t want to spend the time to read all of them
through &lt;strong&gt;I&amp;#39;ve decided to narrow my efforts to the most popular ones and
research those.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="what-to-choose"&gt;What to choose?&lt;/h2&gt;
&lt;p&gt;To find out which licenses are most popular in the community I decided to look
at GitHub&amp;#39;s data on Google&amp;#39;s BigQuery platform. GitHub catalogs all repositories
by the license they use. It&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/2400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2664; --attachment-height: 736; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAACwAQCdASoQAAQABABoJZwAAudidskgAP701AW0miyw7Wh%2B1yDKrKoj5M9AAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #edf0ed;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Representation of licenses in all GitHub projects as a percentage" loading="lazy" decoding="async" class="attachment__image" width="2664" height="736" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/1200w.png" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/480w.png 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/800w.png 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/1200w.png 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/1600w.png 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/d03sraeqi3hzgi0v_18dig.png/2400w.png 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Representation of licenses in all GitHub projects as a percentage&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;The above graph represents the percentage of repositories using a particular
license.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To get that data from BigQuery I&amp;#39;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;/p&gt;
&lt;pre class&gt;&lt;span class="line"&gt;&lt;span&gt;SELECT licenses.license AS license, COUNT(licenses.repo_name) AS count&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;FROM [bigquery-public-data:github_repos.licenses] AS licenses&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;JOIN [bigquery-public-data:github_repos.languages] AS languages ON licenses.repo_name = languages.repo_name&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;WHERE languages.language.name = &amp;#39;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span&gt;GROUP BY license&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It&amp;#39;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&amp;#39;s also visible that there
is a relatively small amount of unlicensed, custom-licensed and multi-licensed
repositories.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;h2 id="mit"&gt;MIT&lt;/h2&gt;
&lt;p&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&amp;#39;s
also the simplest of the five.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://twitter.com/dhh"&gt;DHH&lt;/a&gt; (David Heinemeier Hansson) once translated it
nicely as &amp;quot;&lt;a href="https://www.flickr.com/photos/rooreynolds/243810133/"&gt;&lt;strong&gt;I don&amp;#39;t owe you
shit&lt;/strong&gt;&lt;/a&gt;&amp;quot;.&lt;/p&gt;
&lt;p&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&amp;#39;t be
held liable for any bugs or unintended side-effects.&lt;/p&gt;
&lt;p&gt;That means that people are free to include your code in their projects (provided
the copyright/license notice), you can&amp;#39;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;/p&gt;
&lt;p&gt;&lt;strong&gt;This license is extremely permissive making it an excellent choice for
libraries, as well as applications.&lt;/strong&gt; It&amp;#39;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&amp;#39;t force the end-user&amp;#39;s choice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;There is a gotcha&amp;#39; 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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 1696; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2014%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAADQAQCdASoQAA4ABABoJZwAAudgXvfpUAD%2B9NbbJpcjJVOokbyHJrjvt46wcodaoFCLDC6lsBRAgH8AAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f7f2f4;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&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" decoding="async" class="attachment__image" width="2000" height="1696" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/1200w.png" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/480w.png 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/800w.png 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/1200w.png 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/1600w.png 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/y6vd5b6zkq9da6jxq6tbbw.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;License popularity by language, expressed as a percentage; EDIT: &amp;quot;Unlicensed&amp;quot; is actually the Unlicense license&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;d attribute it&amp;#39;s popularity to
it&amp;#39;s do-what-you-want approach to licensing.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="apache-20"&gt;Apache-2.0&lt;/h2&gt;
&lt;p&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&amp;#39;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&amp;#39;t infringe on any
patents.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 1133; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkoAAABXRUJQVlA4ID4AAADQAQCdASoQAAkABABoJZQAAurarIAUAAD%2B79yd%2Fwpwvq4A%2B1FWFdLz%2F9qfvfcEUqEzfsXPg1NAFbfABpQAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #edf1ec;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="License popularity in Java &amp;amp; Kotlin projects, expressed as a percentage" loading="lazy" decoding="async" class="attachment__image" width="2000" height="1133" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/1200w.png" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/480w.png 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/800w.png 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/1200w.png 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/1600w.png 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/pe72mku20owlcgsftvfxvw.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;Interestingly enough, Apache-2.0 is the most popular license for Java and Kotlin
projects. I&amp;#39;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&amp;#39; I can&amp;#39;t explain exactly why these two languages
are the outliers.&lt;/p&gt;
&lt;h2 id="gplv2--gplv3"&gt;GPLv2 &amp;amp; GPLv3&lt;/h2&gt;
&lt;p&gt;Here &lt;strong&gt;I&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;t be held liable,
you can patent your work, redistribute and modify the original work (with a
notice).&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Some people don&amp;#39;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&amp;#39;s source publicly accessible. In other words,
you can view the code of an open-source, but you can&amp;#39;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;/p&gt;
&lt;p&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 &amp;quot;The Magic Cauldron&amp;quot; from
the book &amp;quot;&lt;a href="https://en.wikipedia.org/wiki/The_Cathedral_and_the_Bazaar"&gt;The Cathedral &amp;amp; the
Bazaar&lt;/a&gt;&amp;quot; covers the
commercial aspect of free &amp;amp; open-source development in great detail, for anyone
interested.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The GPLv3 is best applicable for applications. This doesn&amp;#39;t mean that it&amp;#39;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&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;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&amp;#39;s statically linked into the
derivative, but it can be dynamically linked without repercussions.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 1143; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkQAAABXRUJQVlA4IDgAAACwAQCdASoQAAkABABoJZQAAudisHQAAP7003ABoYfZQaTGRqzNDQkuQ6fLXnyFlcroubLgIEAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #edeee9;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="License popularity in C &amp;amp; C++ projects, expressed as a percentage" loading="lazy" decoding="async" class="attachment__image" width="2000" height="1143" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/1200w.png" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/480w.png 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/800w.png 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/1200w.png 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/1600w.png 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/kpekttpshuuyan0aixrcdq.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;h2 id="bsd-3-clauses"&gt;BSD-3-clauses&lt;/h2&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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&amp;#39;s
author(s)&lt;/strong&gt; (without prior permission).&lt;/p&gt;
&lt;p&gt;Note that &lt;a href="https://opensource.org/licenses/BSDplusPatent"&gt;there exists a &lt;strong&gt;-patents&lt;/strong&gt; 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;/p&gt;
&lt;p&gt;&lt;strong&gt;BSD-3-clauses is excellent for both libraries and applications&lt;/strong&gt;. It&amp;#39;s not as
verbose or popular as Apache-2.0, yet it&amp;#39;s as permissive as MIT, the biggest
differentiating factor is it&amp;#39;s restriction on marketing.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 540; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjoAAABXRUJQVlA4IC4AAACwAQCdASoQAAQABABoJZQBTBA%2F4JAAAP7xBIixGFCYNcvzsQdRtFSJDwpTNeAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #edfaf6;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/480w.webp 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/800w.webp 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="License popularity in Haskell projects, expressed as a percentage" loading="lazy" decoding="async" class="attachment__image" width="2000" height="540" src="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/1200w.png" srcset="https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/480w.png 480w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/800w.png 800w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/1200w.png 1200w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/1600w.png 1600w, https://media.stanko.io/variants/articles/licensing-software-b13d043e2a93/a4mjwc-vb9ekcwvyd_kvmq.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;License popularity in Haskell projects, expressed as a percentage&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;It&amp;#39;s interesting that out of all the sampled languages that Haskell&amp;#39;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;/p&gt;
&lt;h2 id="clauses"&gt;Clauses&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Clauses are envisioned to fulfill the author&amp;#39;s additional wishes or change
some behavior of the base license.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Perhaps the most well known example is GPL and LGPL. Where LGPL is basically a
clause, and requires, the full GPL.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Picking a license is hard. It boils down to what you want other people to do
with your work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you really don&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;If in addition you want to protect the authors&amp;#39;/contributors&amp;#39; privacy and
control how people can use your project to market theirs&lt;/strong&gt;, then BSD is the
choice for you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you want to protect the users&amp;#39; 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;/p&gt;
&lt;p&gt;&lt;strong&gt;I usually use the MIT license only for libraries&lt;/strong&gt; and GPLv3 for applications,
as I don&amp;#39;t want to force people to open-source their projects, but after doing
this research I think I&amp;#39;ll choose LGPL for my Ruby library. In my opinion it&amp;#39;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;/p&gt;
&lt;p&gt;While reasearching this topic I&amp;#39;ve found a hand full of useful resources to do
quick checks, they can&amp;#39;t really replace reading the license text, but they give
an insightful overview:&lt;/p&gt;
&lt;p&gt;I have also stumbled upon a few hilarious (yet functional) licenses:&lt;/p&gt;
&lt;p&gt;The data used to generate the statistics can be found here. It was gathered on
the 25th of April, 2018 — it shouldn&amp;#39;t be affected by the recent migration of
projects from GitHub after the acquisition from Microsoft.&lt;/p&gt;
&lt;h2 id="epilogue"&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&amp;#39;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: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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 800; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjwAAABXRUJQVlA4IDAAAADwAQCdASoQAAYABABoJZACdAECmZHmWoAA%2Ft7vmFKbQE1A%2Fw%2FFjUUUf4uwRgaAAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #bcdae8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/480w.webp 480w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/800w.webp 800w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Cloud was made by Fabián Alexis (CC BY-SA 3.0), via Wikimedia Commons" loading="lazy" decoding="async" class="attachment__image" width="2000" height="800" src="https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/1200w.png" srcset="https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/480w.png 480w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/800w.png 800w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/1200w.png 1200w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/1600w.png 1600w, https://media.stanko.io/variants/articles/do-you-really-need-websockets-343aed40aa9b/cloud.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;
&lt;p&gt;Over the years I&amp;#39;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;/p&gt;
&lt;p&gt;Every time I worked on a project where we had to implement any kind of a
&amp;quot;real-time&amp;quot; component, usually a chat or an event feed, the word WebSockets
started to circulate. Though, most people use them because they either aren&amp;#39;t
aware of the alternatives, or they blindly follow other people&amp;#39;s examples.&lt;/p&gt;
&lt;h2 id="why-websockets"&gt;Why WebSockets?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;WebSockets enable the server and client to send messages to each other at any
time&lt;/strong&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_%28telecommunications%29#Full_duplex"&gt;full-duplex
connection&lt;/a&gt;
between the client and the server.&lt;/p&gt;
&lt;p&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_%28computer_science%29"&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;strong&gt;with
Websockets the server can push new data at any time which makes them the better
candidate for &amp;quot;real-time&amp;quot; applications.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;#39;s important to note that WebSockets convert their HTTP connection to a
WebSocket connection.&lt;/strong&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/v2xywm5psk_f5vzywdmpoq.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1000; --attachment-height: 200; "&gt;  &lt;img alt="Animation of a WebSocket connection being established" loading="lazy" decoding="async" class="attachment__image" width="1000" height="200" src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/v2xywm5psk_f5vzywdmpoq.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of a WebSocket connection being established&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;strong&gt;though&lt;/strong&gt; &lt;a href="https://www.nginx.com/blog/websocket-nginx/"&gt;&lt;strong&gt;they aren&amp;#39;t compatible with most load balancers
out-of-the-box&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;and have no
re-connection handling mechanism.&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight" data-language="javascript"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Create WebSocket connection.&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;const&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; socket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; WebSocket&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;ws://localhost:8080&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Connection opened&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;socket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.addEventListener&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;open&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (event) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    socket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;Hello Server!&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;});&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;// Listen for messages&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;socket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.addEventListener&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;message&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; function&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; (event) {&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    console&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;.log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;Message from server &amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;.data);&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&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;, 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;/p&gt;
&lt;p&gt;&lt;strong&gt;In Ruby, there are a few gems that add WebSockets to your web app.&lt;/strong&gt; The one
I&amp;#39;ve mostly used is Faye, though I&amp;#39;ve been looking at websocket-ruby lately.
Rails supports them out-of-the-box since version 5 through ActionCable.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;require&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;faye/websocket&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;App&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; lambda&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;env&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  if&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Faye&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;WebSocket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;websocket?&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(env)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ws &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Faye&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;WebSocket&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(env)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ws&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :message&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      ws&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;send&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;data&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ws&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; :close&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      p&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:close&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;code&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; event&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;reason&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      ws &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; nil&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # Return async Rack response&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ws&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;rack_response&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  else&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # Normal HTTP request&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;200&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; {&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;Content-Type&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;text/plain&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; [&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;Hello&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;In my opinion, &lt;strong&gt;all of those implementations are more-or-less the same — you
can&amp;#39;t really go wrong.&lt;/strong&gt; Note that some gems also require their own JS library
(mostly to encode and decode data that&amp;#39;s being sent or received).&lt;/p&gt;
&lt;h2 id="server-sent-events"&gt;Server-sent events&lt;/h2&gt;
&lt;p&gt;From my experience, most people don&amp;#39;t know that regular old &lt;strong&gt;HTTP provides a
mechanism to push data from the server to clients via&lt;/strong&gt; &lt;a href="https://en.wikipedia.org/wiki/Server-sent_events"&gt;&lt;strong&gt;Server-Sent
Events&lt;/strong&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Server-Sent Events utilize a regular HTTP octet streams&lt;/strong&gt;, and therefore are
limited to the browser&amp;#39;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;strong&gt;they provide a standard way of pushing data from the server to the clients
over HTTP&lt;/strong&gt;, which load balancers and proxies understand out-of-the-box. The
biggest advantage being that, exactly as WebSockets, they &lt;strong&gt;utilize only one TCP
connection&lt;/strong&gt;. The biggest disadvantage is that &lt;strong&gt;Server-Sent Events don&amp;#39;t
provide a mechanism to detect dropped clients&lt;/strong&gt; until a message is sent.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/k2j4qncy_0i3gunnkxq2_g.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1000; --attachment-height: 200; "&gt;  &lt;img alt="Animation of a Server-Sent Events connection being established" loading="lazy" decoding="async" class="attachment__image" width="1000" height="200" src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/k2j4qncy_0i3gunnkxq2_g.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of a Server-Sent Events connection being established&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;a href="http://edgeapi.rubyonrails.org/classes/ActionController/Live/SSE.html"&gt;&lt;strong&gt;Rails&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;,&lt;/strong&gt;
&lt;a href="https://github.com/jeremyevans/roda/blob/master/lib/roda/plugins/streaming.rb"&gt;&lt;strong&gt;Roda&lt;/strong&gt;&lt;/a&gt;
&lt;strong&gt;and&lt;/strong&gt; &lt;a href="https://gist.github.com/rkh/1476463"&gt;&lt;strong&gt;Sinatra&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;support them
out-of-the-box.&lt;/strong&gt; And they have a simple protocol — payloads are just prefixed
with one of the following keywords &lt;strong&gt;data:&lt;/strong&gt;, &lt;strong&gt;event:&lt;/strong&gt;, &lt;strong&gt;id:&lt;/strong&gt; and
&lt;strong&gt;retry:&lt;/strong&gt;. &lt;strong&gt;data:&lt;/strong&gt; is used to push a payload, &lt;strong&gt;event:&lt;/strong&gt; is optional and
indicates the type of data being pushed, &lt;strong&gt;id:&lt;/strong&gt; is also optional and indicates
the event&amp;#39;s ID, finally &lt;strong&gt;retry:&lt;/strong&gt; instructs the client to change it&amp;#39;s
connection retry timeout — &lt;strong&gt;unlike WebSockets, Server-Sent Events have a
reconnect mechanism built-in&lt;/strong&gt;, though this is a feature that most WebSocket
libraries add any way.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# frozen_string_literal: true&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; App&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Roda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  QUEUES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Thread&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    loop&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      sleep&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;60&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      QUEUES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;q&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; q &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;heartbeat:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; } }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  plugin &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:streaming&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  plugin &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;:render&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; engine:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;slim&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  route &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;root&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      view(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;root&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; layout:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; false&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;messages&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;post&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        name &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;params&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;name&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        message &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;params&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;message&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        object &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;name:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; name&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; message:&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; message }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        QUEUES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;each&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;q&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; q &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; object }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        object&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;get&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;stream&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      response[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;Content-Type&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;] &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;text/event-stream;charset=UTF-8&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      q &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Queue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;      QUEUES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; q&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      q &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;heartbeat:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;      stream(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;loop:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; true&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;,&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; callback:&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc { &lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;QUEUES&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;delete(q)&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; }) &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;out&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        loop&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;          out &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;data: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{q&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;pop&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;to_json&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;\n\n&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;In the example above, &lt;strong&gt;to send data to other clients a regular HTTP POST
request is made&lt;/strong&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 id="long-polling"&gt;Long polling&lt;/h2&gt;
&lt;p&gt;Before there were Server-Sent Events people usually resorted to long polling to
receive &amp;quot;real-time&amp;quot; data from the server. Not to give the wrong impression, long
polling is still used today in some scenarios.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Long polling utilizes regular HTTP requests&lt;/strong&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/piakrkmpmripwafmz83nzq.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 1000; --attachment-height: 200; "&gt;  &lt;img alt="Animation of a Long Polling connection being established" loading="lazy" decoding="async" class="attachment__image" width="1000" height="200" src="https://media.stanko.io/originals/articles/do-you-really-need-websockets-343aed40aa9b/piakrkmpmripwafmz83nzq.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of a Long Polling connection being established&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Long polling&amp;#39;s biggest advantage is the fact that it works in every
environment&lt;/strong&gt;, and in every browser. &lt;strong&gt;It&amp;#39;s, arguably, better for applications
with large numbers of concurrent users&lt;/strong&gt; because it doesn&amp;#39;t require a constant
TCP connection to the server (it&amp;#39;s harder to starve the server of TCP
connections since they are periodically released). &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;. And the server needs to implement some kind of event aggregation to
overcome blackouts between re-connects. &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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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; There are some subtle differences and
incompatibilities, but there are libraries that solve those issues.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you don&amp;#39;t need to send data to the server in &amp;quot;real-time&amp;quot; (e.g. voice/video
chat, multiplayer games, …) go with Server-Sent Events.&lt;/strong&gt; They are the standard
HTTP way of pushing data (like notifications, messages or events) to clients.
And &lt;strong&gt;they can be added just by implementing an additional endpoint in a
controller&lt;/strong&gt;, lowering the need to refactor the existing code base (in a well
structured code base the &amp;quot;implementation cost&amp;quot; of both WS and SSE is the same),
and making them somewhat faster to implement (and bring the feature to market).&lt;/p&gt;
&lt;p&gt;I don&amp;#39;t have experience with long polling on projects with large numbers of
concurrent users, so I can&amp;#39;t attest the claims I&amp;#39;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&amp;#39;s Messenger to add PGP to
it&lt;/a&gt;
I noticed that they utilize long polling to fetch messages. &lt;strong&gt;On smaller
projects I would recommend SSE over Long polling since it&amp;#39;s easier to implement
on both the server and client side.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;If you are already using WebSockets or Long polling, don&amp;#39;t go and convert them
to Server-Sent Events. &lt;strong&gt;All solutions are basically the same when it comes to
pushing data from the server to clients.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;WebSockets&lt;/th&gt;
&lt;th&gt;Server Sent Events&lt;/th&gt;
&lt;th&gt;Long Polling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Number of parallel connections from the browser&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;~6 per server&lt;/td&gt;
&lt;td&gt;~6 per server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Load balancing and proxying&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires additional configuration&lt;/td&gt;
&lt;td&gt;Out-of-the-box&lt;/td&gt;
&lt;td&gt;Out-of-the-box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supported on all browsers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (90%)&lt;/td&gt;
&lt;td&gt;No (84%)&lt;/td&gt;
&lt;td&gt;Yes (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dropped client detection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reconnection handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/2160w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2160; --attachment-height: 1000; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAAAwAgCdASoQAAcABABoJbACdLoAAzeMCpQAAAD%2B1DeCqSTdCzlZZh8OMRg5PmIdiHPftaInzSExzi38eksKcTdNqgAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f4b15a;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/2160w.webp 2160w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A rabbit dressed up as the karate kid - a cross of RabbitMQ and Sidekiq logos" loading="lazy" decoding="async" class="attachment__image" width="2160" height="1000" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/1200w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/1200w.png 1200w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/1600w.png 1600w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/rabbit_karate.png/2160w.png 2160w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;
&lt;p&gt;I&amp;#39;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;/p&gt;
&lt;p&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;h2 id="why-do-we-need-sidekiq-or-rabbitmq"&gt;Why do we need Sidekiq or RabbitMQ?&lt;/h2&gt;
&lt;p&gt;&amp;quot;Background job&amp;quot; libraries like Sidekiq are used in situations when &lt;strong&gt;the result
of a process can be yielded, but further side effects still need to be caused.&lt;/strong&gt;&lt;/p&gt;
&lt;p&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&amp;#39;s account will be created even if the
confirmation email fails to get sent.&lt;/p&gt;
&lt;p&gt;It&amp;#39;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&amp;#39;t be able to create a new
account since their email is already taken. &lt;strong&gt;We are penalizing the user for
something that isn&amp;#39;t their fault&lt;/strong&gt;, something they can&amp;#39;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;/p&gt;
&lt;p&gt;To resolve those issues we offload that work to a &amp;quot;background job&amp;quot; library. In
the case of the signup example, the user&amp;#39;s account would be created and they
would be logged in. The &amp;quot;send email&amp;quot; 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;strong&gt;The user isn&amp;#39;t penalized for
our mistake.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But do we really need Sidekiq for this?&lt;/strong&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 class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; App&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; &amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; Roda&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  JOBS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Queue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;  Thread&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;new&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    loop&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      begin&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        job &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; JOBS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;pop&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        job&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;call&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      rescue&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; =&amp;gt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; e&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;        puts&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;quot;ERROR: &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;#{e}&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        JOBS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; job&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  route &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;do&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt; |&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;on&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt; &amp;#39;sign_up&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;      r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;post&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;        email &lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;=&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt; r&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;params&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;[&lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;email&amp;#39;&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;        JOBS&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; proc&lt;/span&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;          Mailer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;ConfirmationMailer&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;deliver&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(email)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;        end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;h2 id="why-do-we-use-background-workers-then"&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&amp;#39;ll focus on &lt;strong&gt;debuggability&lt;/strong&gt; (yes, I made that word up), &lt;strong&gt;persistence&lt;/strong&gt;,
&lt;strong&gt;scaling&lt;/strong&gt; and lastly &lt;strong&gt;fault tolerance&lt;/strong&gt;.&lt;/p&gt;
&lt;p&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&amp;#39;t even
extract the job that caused the issue to replicate it. &lt;strong&gt;To solve those issues
Sidekiq uses&lt;/strong&gt; &lt;a href="https://redis.io/"&gt;&lt;strong&gt;Redis&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;to store it&amp;#39;s jobs.&lt;/strong&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&amp;#39;s content by
connecting to Redis with redis-cli and inspecting the queue list.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 238; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%204%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiwAAABXRUJQVlA4ICAAAAAwAQCdASoQAAQABABoJZwAA3AA%2Fu8PHnxAYLb8jhWAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #343240;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Contents of the default queue in Redis" loading="lazy" decoding="async" class="attachment__image" width="1000" height="238" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/1000w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/cskeexdbb7evp8x129dvww.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Contents of the default queue in Redis&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;h2 id="sidekiqs-memory-problem"&gt;Sidekiq&amp;#39;s memory problem&lt;/h2&gt;
&lt;p&gt;There are two concerns regarding fault tolerance, Redis and the worker process.
By default, &lt;strong&gt;Redis is a volatile store&lt;/strong&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;/p&gt;
&lt;p&gt;When it comes to worker failures, Sidekiq handles most issues regarding handling
Ruby errors and retry logic. &lt;strong&gt;But if a worker crashes or is killed while
processing a job, that job is gone.&lt;/strong&gt;&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/pn5ldfeiowaimyzfqemmeg.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of how a job can disappear from Sidekiq in the case of a worker restart/crash" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/pn5ldfeiowaimyzfqemmeg.gif"&gt; &lt;/picture&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;
&lt;p&gt;The most common cases for job disappearance are &lt;strong&gt;deploys&lt;/strong&gt; (since the
application is killed at that point, though Sidekiq offers rolling restarts with
it&amp;#39;s Pro plan) and &lt;strong&gt;VM crashes&lt;/strong&gt; caused by faulty gem extensions (as they can&amp;#39;t
be caught).&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 153; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%202%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAACQAQCdASoQAAIABABoJaQAAudLQswA%2Fu%2FLLHiwAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #eeeeee;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Screenshot of Sidekiq's README about error handeling" loading="lazy" decoding="async" class="attachment__image" width="1000" height="153" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/1000w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ildbyqygdm_br_4vv1yg5w.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Screenshot of Sidekiq&amp;#39;s README about error handeling&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;s recurring) for
small/personal projects which lead me to look for alternatives.&lt;/p&gt;
&lt;p&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;strong&gt;all jobs in the queue are kept in-memory all the time&lt;/strong&gt;.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 555; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoQAAkABABoJaQAA3AA%2FvE1DaLfHuZgAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f8f8f8;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Redis memory usage plot around peak usage times" loading="lazy" decoding="async" class="attachment__image" width="1000" height="555" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/1000w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/hzcnzhmtlzr6xbui5xl7tw.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Redis memory usage plot around peak usage times&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;The most common solution to this issue is passing IDs of database records
instead of values to the job queue. &lt;strong&gt;This reduces Redis&amp;#39; memory consumption,
but increases Sidekiq&amp;#39;s or Ruby&amp;#39;s&lt;/strong&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&amp;#39;s API, but then we are increasing load on our app instead of
off-loading work from it.&lt;/p&gt;
&lt;h2 id="how-rabbitmq-solves-those-issues"&gt;How RabbitMQ solves those issues&lt;/h2&gt;
&lt;p&gt;RabbitMQ is a general purpose message queue. To utilize it in a &amp;quot;background
worker&amp;quot; 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&amp;#39;s syntax.&lt;/p&gt;
&lt;pre class="highlight" data-language="ruby"&gt;&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SneakersLogger&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Sneakers&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Worker&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;  # Defines the queue and it&amp;#39;s options&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;  from_queue &lt;/span&gt;&lt;span style="color:var(--astro-code-token-string-expression)"&gt;&amp;#39;loggings&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; work&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;log_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(log_message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;    # Does magic that will be explaned in the next section&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;    ack!&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-comment)"&gt;# ---&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;class&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; SidekiqLogger&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  include&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt; Sidekiq&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;::&lt;/span&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;Worker&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  def&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt; perform&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(&lt;/span&gt;&lt;span style="color:var(--astro-code-token-parameter)"&gt;log_message&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-constant)"&gt;    Logger&lt;/span&gt;&lt;span style="color:var(--astro-code-token-punctuation)"&gt;.&lt;/span&gt;&lt;span style="color:var(--astro-code-token-function)"&gt;log&lt;/span&gt;&lt;span style="color:var(--astro-code-foreground)"&gt;(log_message)&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class="line"&gt;&lt;span style="color:var(--astro-code-token-keyword)"&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The biggest difference between the two implementations is the &lt;strong&gt;ack!&lt;/strong&gt; on
line 10. &lt;strong&gt;That line enables Sneakers and RabbitMQ to guarantee that a job has
been processed.&lt;/strong&gt; This is a feature of RabbitMQ&amp;#39;s communication protocol — AMQP.
In AMQP a message can be popped from a queue in two modes — ack mode and no-ack
mode.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/yealpgldxalxe0afcwh79g.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of a consumer failing and succeeding in ACK mode" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/yealpgldxalxe0afcwh79g.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of a consumer failing and succeeding in ACK mode&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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; When the consumer pops a message from the queue it&amp;#39;s
virtually removed from it, but RabbitMQ still keeps a copy of it. &lt;strong&gt;If the
consumer fails to send an &amp;quot;ack&amp;quot; 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;
If the consumer sends an &amp;quot;ack&amp;quot; signal in the specified time period the message
is fully removed from RabbitMQ. &lt;strong&gt;In no-ack mode no guarantees are given&lt;/strong&gt;, no
time window has to be specified, and no &amp;quot;ack&amp;quot; signal has to be sent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Another difference is memory consumption.&lt;/strong&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 &amp;quot;lazy queue&amp;quot; which keeps all it&amp;#39;s
messages on disk if possible — it&amp;#39;s useful for passing large messages.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/700w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 700; --attachment-height: 412; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRsoAAABXRUJQVlA4WAoAAAAQAAAADwAACAAAQUxQSEoAAAABcBvbtlJd3F0iaIQO3N0KgOzWTipfMiqICCFBIskrAKyqwJ8k6goFwITbFLg872bNuggA1vM3Tc%2Bf%2FVbX2b04PCLuhec3ByIKAFZQOCBaAAAAUAIAnQEqEAAJAAQAaCWwAnR%2FALcAVLi6jmsAAP6jRmd4cHXC50KYer6OGd2KhEXNLJ62nEJrmwC1WfyVcHxC0HGUjFKiFEoiDkzuyNf7A%2Be8tYvz0d5uHQAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #8d5860;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/700w.webp 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Illustration of RabbitMQ resource allocation per message in a normal and a lazy queue" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/700w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/z_qyc0chosqm-pdy0jvpa.png/700w.png 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&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;p&gt;&lt;strong&gt;With those features we can create reliable job queues which can handle larger
payloads.&lt;/strong&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;There is still one feature of Sidekiq I haven&amp;#39;t mentioned — the UI. Sidekiq&amp;#39;s UI
is useful for monitoring the health of your jobs, and general throughput.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 615; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlYAAABXRUJQVlA4WAoAAAAQAAAADwAACQAAQUxQSA4AAAABD9D%2FiAjMf0T%2FYwAAAFZQOCAiAAAAkAEAnQEqEAAKAAQAaCWkAALnV8eAAP7zafCnNCsYqwAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #e6e6e6;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Sidekiq UI" loading="lazy" decoding="async" class="attachment__image" width="1000" height="615" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/1000w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2s8gt8c0hkpfzzl_i6cidq.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Sidekiq UI&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;RabbitMQ also comes with a powerful UI&lt;/strong&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 614; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRmQAAABXRUJQVlA4WAoAAAAQAAAADwAACQAAQUxQSB0AAAABHyAWTPwVE%2BmcRkQEB8Jso0rO5fx%2FZxDR%2F8TcVQBWUDggIAAAADABAJ0BKhAACgAEAGglpAADcAD%2B8hetlCcPBChkhIAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f9f9f9;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/800w.webp 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="RabbitMQ Management Console" loading="lazy" decoding="async" class="attachment__image" width="1000" height="614" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/1000w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/800w.png 800w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ekcsvlntnmmqu1uwolofuw.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;RabbitMQ Management Console&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h2 id="exchanges"&gt;Exchanges&lt;/h2&gt;
&lt;p&gt;AMQP defines the concept of exchanges. Exchanges can be thought of as routers.
&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; It&amp;#39;s important to note that it&amp;#39;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;/p&gt;
&lt;p&gt;&lt;strong&gt;There are four types of exchanges supported by RabbitMQ.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most commonly used exchange type is the &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html#exchange-direct"&gt;&lt;strong&gt;direct
exchange&lt;/strong&gt;&lt;/a&gt;.
It directly delivers all messages to a single queue bound to it. &lt;strong&gt;It&amp;#39;s a 1-on-1
mapping of an exchange to a queue.&lt;/strong&gt; If applied to a chat application, a direct
exchange would deliver messages from a chat room to a single user.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/09g-3e6jmvq5c5bkj-cydg.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of a direct exchange being utilized in a chat app" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/09g-3e6jmvq5c5bkj-cydg.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of a direct exchange being utilized in a chat app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Another kind of exchange is the &lt;strong&gt;fan-out exchange&lt;/strong&gt;. They deliver messages to
all queues bound to them. &lt;strong&gt;It&amp;#39;s a 1-to-many mapping of an exchange to multiple
queues.&lt;/strong&gt; In the example of the chat application, a fan-out exchange would be
used to send a message to all users.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2fezqaffqpihawmv0g8rxw.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of a fan-out exchange being utilized in a chat app" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/2fezqaffqpihawmv0g8rxw.gif"&gt; &lt;/picture&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;p&gt;Then there are &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;They deliver messages published to them to a bound queue based on the messages
tag/topic and the queue&amp;#39;s bound topic.&lt;/strong&gt; In the example of the chat application,
a topic exchange would be used to direct messages to their corresponding user.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/f2lzm5ok-dyydfwfk9rexw.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of a topic exchange being utilized in a chat app" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/f2lzm5ok-dyydfwfk9rexw.gif"&gt; &lt;/picture&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;p&gt;Finally, there are &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;.
They are a step up from topic exchanges. &lt;strong&gt;Instead of looking at a topic, also
called a routing key, they look at the message&amp;#39;s headers to determine where a
message should be delivered.&lt;/strong&gt; Messages in RabbitMQ can have additional
attributes associated with them — called headers. Headers determine different
behaviors when handling messages. E.g. an &amp;quot;x-match&amp;quot; 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 &amp;quot;reply-to&amp;quot; header which indicates where the
result of processing a message should be published to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Utilizing exchanges gives many advantages&lt;/strong&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;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;/p&gt;
&lt;h2 id="special-features"&gt;&lt;strong&gt;Special features&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;RabbitMQ adds it&amp;#39;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;strong&gt;&amp;quot;&lt;/strong&gt;&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;&amp;quot;&lt;/strong&gt;. Direct reply-to
is a form of synchronous communication between a producer and a consumer. &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; It&amp;#39;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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/xcie9e2hlbfyxrpeiwswrg.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of utilizing a direct reply-to message" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/xcie9e2hlbfyxrpeiwswrg.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of utilizing a direct reply-to message&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Another feature that I often utilize is &lt;strong&gt;&amp;quot;&lt;/strong&gt;&lt;a href="https://www.rabbitmq.com/dlx.html"&gt;&lt;strong&gt;dead
lettering&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt;. It allows for a message to
be re-queued automatically in case it gets rejected from an exchange or queue.
&lt;strong&gt;This feature is useful for error handling&lt;/strong&gt;, exponential back-off, scheduled
message processing …&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/fjwenuxnbdgczk162fs02q.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of utilizing a dead letter queue" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/fjwenuxnbdgczk162fs02q.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of utilizing a dead letter queue&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt;&lt;a href="https://www.rabbitmq.com/ae.html"&gt;&lt;strong&gt;Alternate exchange&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt; is a useful
feature for deprecating services. &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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ogy1ioclzrfhuprm2fsapg.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of utilizing an alternate exchange" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/ogy1ioclzrfhuprm2fsapg.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of utilizing an alternate exchange&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Then there are &lt;strong&gt;&amp;quot;&lt;/strong&gt;&lt;a href="https://www.rabbitmq.com/priority.html"&gt;&lt;strong&gt;priority
queues&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt; and &lt;strong&gt;&amp;quot;&lt;/strong&gt;&lt;a href="https://www.rabbitmq.com/consumer-priority.html"&gt;&lt;strong&gt;priority
consumers&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt;. Priority
queue are CS standard priority queues. Meaning that messages in the queue have a
priority (ranging from 0 to 255), &lt;strong&gt;messages with higher priority get processed
first&lt;/strong&gt;. Which is useful for the same reasons as &amp;quot;direct reply-to&amp;quot;, but it&amp;#39;s
asynchronous. While &lt;strong&gt;priority consumers are a form of fail-over&lt;/strong&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;/p&gt;
&lt;p&gt;Finally there is &lt;strong&gt;&amp;quot;&lt;/strong&gt;&lt;a href="https://www.rabbitmq.com/ttl.html"&gt;&lt;strong&gt;TTL&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&amp;quot;&lt;/strong&gt;. It
specifies how long a message lives. &lt;strong&gt;If a message outlives its TTL it&amp;#39;s
automatically rejected from the queue.&lt;/strong&gt; Though, this feature comes with a
caveat — &lt;strong&gt;this rule can only be enforced for the message at the front of the
queue&lt;/strong&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/c9ic606eev0_sw5a4dadpq.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 700; --attachment-height: 412; "&gt;  &lt;img alt="Animation of TTL messages combined with dead lettering" loading="lazy" decoding="async" class="attachment__image" width="700" height="412" src="https://media.stanko.io/originals/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/c9ic606eev0_sw5a4dadpq.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Animation of TTL messages combined with dead lettering&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h2 id="plugins"&gt;Plugins&lt;/h2&gt;
&lt;p&gt;For me, this is the most important feature of RabbitMQ. &lt;strong&gt;With plugins you can
add any functionality you want to RabbitMQ&lt;/strong&gt;. The best example of this is the
management console which is a plugin, and must be enabled before use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Through plugins, RabbitMQ supports not only AMQP, but STOMP, MQTT and
WebSockets as communication protocols.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Then there is the &lt;strong&gt;Federation plugin&lt;/strong&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;strong&gt;Federations are useful for handling large workloads.&lt;/strong&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/700w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 700; --attachment-height: 427; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRvAAAABXRUJQVlA4WAoAAAAQAAAADwAACQAAQUxQSG8AAAABcFXbtqrs5%2F4e7u7u7i4J%2FIsoNKAB%2FyRhUOsdMkTEBACAHFRN0woYoD9zUVy%2FRSp8P6UL20eM8jLpQCDNeCkPH3Q6w5yHUEpDeDwY5m30yPHb9ubz3tZvFI4AgOCLYDVAuKqDlIKvZ1DC34EA7AEAVlA4IFoAAABQAgCdASoQAAoABABoJaACdH8AgnTFAkgESAAA%2Frn8tS2w38yg8kiu4FlNrxwx5enrczaQeaQg0qX7Sl5p4IezG50yo0TRTX8yiayUoX32Z3iyWKz7HpXlAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #654f37;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/480w.webp 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/700w.webp 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Example RabbitMQ Federation for an IoT application" loading="lazy" decoding="async" class="attachment__image" width="700" height="427" src="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/700w.png" srcset="https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/480w.png 480w, https://media.stanko.io/variants/articles/rabbitmq-is-more-than-a-sidekiq-replacement-b730d8176fb/wlkngwu84jqdccqftugorq.png/700w.png 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Example RabbitMQ Federation for an IoT application&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&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; It supports
multiple industry-standard message queue protocols and can be used as a drop in
replacement for other &amp;quot;background worker&amp;quot; libraries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you need a queue that guarantees job execution and persistence, go with
RabbitMQ instead of Sidekiq.&lt;/strong&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;&lt;strong&gt;Sidekiq isn&amp;#39;t useless!&lt;/strong&gt; If your project doesn&amp;#39;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;strong&gt;a
Pro license offers support and additional business oriented features which you
won&amp;#39;t get with a self-hosted RabbitMQ instance.&lt;/strong&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;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/2160w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2160; --attachment-height: 860; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRrYAAABXRUJQVlA4WAoAAAAQAAAADwAABQAAQUxQSEAAAAABYFNbe5MUigQ2pCAEDVnpaEBCLwqwwspBQx1JTyxExAQA7ZoAy%2FoIjSLS0N0xiH9KKQGGG6X0CwxQe17l4CsAVlA4IFAAAADwAQCdASoQAAYABABoJbACdAYtBE9oNAAA%2FFQsVj1T%2Fc1xOH7Vb8eb2uTtRrZcnLff%2BA5Ce66lWmzIfMXMLpgZbc1p2rDUVvkUW1ZyCygAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #9e8e73;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/2160w.webp 2160w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A rabbit with a tachometer in it's silhouette" loading="lazy" decoding="async" class="attachment__image" width="2160" height="860" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/1200w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/1200w.png 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/1600w.png 1600w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/bunny.png/2160w.png 2160w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A rabbit with a tachometer in it&amp;#39;s silhouette&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;When I first started using RabbitMQ I didn&amp;#39;t understand its usefulness beyond a
job queue, but it&amp;#39;s helped me to grow and manage services architectures without
headaches.&lt;/p&gt;
&lt;h2 id="services-architecture"&gt;Services Architecture&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 452; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%207%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRsAAAABXRUJQVlA4WAoAAAAQAAAADwAABgAAQUxQSF4AAAABcFzbtprcUdxeHXHH3R2G1EADbsM0gEMFdJlQQ0RMADIbFx%2Bp%2BsqB79PsWqfKOQamv6WU7FE%2FhzS6tYDedyElF3lxa1D5GAHeOodKlWqHgMb3NrLnjxr%2BtDTGIi4FVlA4IDwAAADQAQCdASoQAAcABABoJYgCdADbWicbQAD%2B3Ny6CB4aHfWaPDZ1YuWjgYzpECzWdsLfoClXIWyvX45v4AA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #273646;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="An example of a services architecture app" loading="lazy" decoding="async" class="attachment__image" width="1000" height="452" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/1000w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zdwh70y-0ntatt7hwh8cpw.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;An example of a services architecture app&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&gt;A services architecture introduces a new challenge — &lt;strong&gt;working with a
distributed system.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="problems"&gt;&lt;strong&gt;Problems&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;The most common problem is state management or &lt;strong&gt;data consistency&lt;/strong&gt;. &lt;strong&gt;RabbitMQ
doesn&amp;#39;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&amp;#39;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&amp;#39;t be
covered here.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;h2 id="rabbitmq"&gt;RabbitMQ&lt;/h2&gt;
&lt;p&gt;As the name implies, it&amp;#39;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&amp;#39;s a wire-level binary protocol that provides mechanisms to ensure
message delivery and consumption.&lt;/p&gt;
&lt;p&gt;I was drawn to RabbitMQ because of two problems I&amp;#39;ve had while working on a
project, &lt;strong&gt;which other message queue services didn&amp;#39;t solve&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Some message queues &lt;strong&gt;don&amp;#39;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;/p&gt;
&lt;p&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 &amp;quot;lazy queues&amp;quot; which
try to keep everything on disk and nothing in-memory (if possible).&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/1000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1000; --attachment-height: 357; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRiYAAABXRUJQVlA4IBoAAAAwAQCdASoQAAYABUBoJaQAA3AA%2FvIW5NoAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #fbfafb;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/1000w.webp 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Queue statistics" loading="lazy" decoding="async" class="attachment__image" width="1000" height="357" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/1000w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/sgya89fdzp_j11prbwhnmg.png/1000w.png 1000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Queue statistics&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h2 id="rpc--pub-sub"&gt;&lt;strong&gt;RPC / Pub-Sub&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Most people I&amp;#39;ve talked to aren&amp;#39;t familiar with RabbitMQ&amp;#39;s &lt;a href="https://www.rabbitmq.com/direct-reply-to.html"&gt;&lt;strong&gt;&amp;quot;direct
reply-to&amp;quot;&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;/p&gt;
&lt;p&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;/p&gt;
&lt;h2 id="exchanges"&gt;&lt;strong&gt;Exchanges&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;In AMPQ, messages aren&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/1110w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1110; --attachment-height: 430; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRsQAAABXRUJQVlA4WAoAAAAQAAAADwAABQAAQUxQSFQAAAABcFvbtmmd82x1oW%2BblTC2bWT%2B0a%2FWJUTEHsBLMYv2sobYHHPsqIWwmUrhR0%2FkdydROB15eTOVY8%2BxyB8u0ltyKTQnJcT6kIM%2FeA3BrYlujfYbFABWUDggSgAAANABAJ0BKhAABgAEAGglAE6AGisHmwTAAP7pBtAjCZ27AKg%2F5QLLX2P2%2BjgqGRPtsf6L2sEVynV1qkXtdnzlJ%2FAKcdzGhxAOAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #2f3835;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/1110w.webp 1110w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Topic exchange example" loading="lazy" decoding="async" class="attachment__image" width="1110" height="430" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/1110w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/dylm8hewbqrahfs4wma7ea.png/1110w.png 1110w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Topic exchange example&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;RabbitMQ supports four types of exchanges — direct, fan-out, topic and headers.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1400w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1400; --attachment-height: 786; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkgAAABXRUJQVlA4IDwAAACwAQCdASoQAAkABABoJYwCw7DF7rjAAP71FyFeb03kIM1fmPME9yMd08R2Au%2BAiIhyezczEq19IH3GQAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #cfd8db;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1400w.webp 1400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Direct exchange example" loading="lazy" decoding="async" class="attachment__image" width="1400" height="786" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1200w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1200w.png 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/gawkjrboyky4ly5tsw4cca.png/1400w.png 1400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Direct exchange example&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/700w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 700; --attachment-height: 392; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkwAAABXRUJQVlA4IEAAAADQAQCdASoQAAkABABoJYwC7ADF7rlGAAD%2B9RchXm9N49twQ%2BDi4%2BdSAOuyjYR3ibQ2jd3uNWTUmd8RI1JwAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d4dadc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/700w.webp 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Fan-out exchange example" loading="lazy" decoding="async" class="attachment__image" width="700" height="392" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/700w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/uetltk4ppnitugofhteoga.png/700w.png 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Fan-out exchange example&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/700w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 700; --attachment-height: 392; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkoAAABXRUJQVlA4ID4AAACwAQCdASoQAAkABABoJZQC7ADF7ouAAP71FxW911zXScPrFH5KG4Hwc8YB5cuickes2V59gyLKgTn5S0AAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #d4dadc;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/700w.webp 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Topic exchange example" loading="lazy" decoding="async" class="attachment__image" width="700" height="392" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/700w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/wh85oyiow7lcj4vzhi7x0q.png/700w.png 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Topic exchange example&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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
&amp;quot;Fedex&amp;quot; and it&amp;#39;s delivered to the &amp;quot;Shipping&amp;quot; exchange it will be delivered to
the &amp;quot;Fedex&amp;quot; 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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;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
&amp;quot;lives&amp;quot;, though this feature has a caveat — please refer to the manual before
using it), and others.&lt;/p&gt;
&lt;p&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&amp;#39;s and the new service&amp;#39;s queues. Or, when the
new service is up, all incoming messages can be redirected to it.&lt;/p&gt;
&lt;h2 id="service-discovery"&gt;&lt;strong&gt;Service discovery&lt;/strong&gt;&lt;/h2&gt;
&lt;p&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&amp;#39;t exist), and
listen for messages on its queue.&lt;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1523w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 1523; --attachment-height: 532; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%206%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACQAQCdASoQAAYABABoJZwAAu1g2AAA%2Fu4cTGtd13dSNbHtgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #e6e6e6;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1523w.webp 1523w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="List of available queues" loading="lazy" decoding="async" class="attachment__image" width="1523" height="532" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1200w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1200w.png 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/yatuk3y5j_6iozzn29ogjg.png/1523w.png 1523w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;List of available queues&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;h2 id="plugins"&gt;&lt;strong&gt;Plugins&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Personally, I find this to be the &amp;quot;&lt;strong&gt;killer feature&lt;/strong&gt;&amp;quot; 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;/p&gt;
&lt;p&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--png" data-full-src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/2000w.png"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 2000; --attachment-height: 1232; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2010%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRjAAAABXRUJQVlA4ICQAAACwAQCdASoQAAoABABoJaQAAudjYmgAAP701p34PMfJSbtgAAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #f9f9f9;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/480w.webp 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/800w.webp 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/1200w.webp 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/1600w.webp 1600w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/2000w.webp 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Overview panel in the Management plugin" loading="lazy" decoding="async" class="attachment__image" width="2000" height="1232" src="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/1200w.png" srcset="https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/480w.png 480w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/800w.png 800w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/1200w.png 1200w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/1600w.png 1600w, https://media.stanko.io/variants/articles/supercharging-services-architectures-with-rabbitmq-b2dc75804577/zrhh-vwjriiatvnpzyjznq.png/2000w.png 2000w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Overview panel in the Management plugin&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Through plugins &lt;strong&gt;RabbitMQ can support competing protocols to AMPQ&lt;/strong&gt; — like
MQTT, STOMP and WebSockets.&lt;/p&gt;
&lt;p&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&amp;#39;s and Diaspora&amp;#39;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&amp;#39;s accessible through the
Management plugin.&lt;/p&gt;
&lt;p&gt;Lastly, there is a feature called &amp;quot;&lt;strong&gt;firehose&lt;/strong&gt;&amp;quot; that logs all internal messages
of RabbitMQ. It&amp;#39;s extremely useful for debugging plugin behavior, exchange
configuration and even just for logging.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;RabbitMQ has helped me manage and scale my services architectures by utilizing
plugins, exchanges, queues and the RPC interface. It&amp;#39;s become an essential tool
for building services.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My biggest issue now is my dependency on it&lt;/strong&gt; — it&amp;#39;s become the backbone of my
services architectures. This introduced a large single point of failure, but
it&amp;#39;s manageable with the Federation plugin and clustering.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&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 Architecture

In 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: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;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4000; --attachment-height: 2076; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%208%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkIAAABXRUJQVlA4IDYAAADQAQCdASoQAAgABABoJaQAAupyrnrwAAD%2B26xjfdmINZKbxy9ma61CNeOZJaTYUE6ZJcnfgAA%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #5e5e5e;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="A shoe stuck in the railing of a bridge in Stockholm" loading="lazy" decoding="async" class="attachment__image" width="4000" height="2076" src="https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/tsnyl5u5enqjg5xeeysmgg.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;A shoe stuck in the railing of a bridge in Stockholm&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&gt;Peoples&amp;#39; 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&amp;#39;t exist code
that&amp;#39;s too hard to test or code that can&amp;#39;t fail. You shouldn&amp;#39;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&amp;#39;t hard. But to do it
right is extremely hard.&lt;/p&gt;
&lt;p&gt;As I said earlier, all code is &amp;#39;testable&amp;#39;. There only exists the age-old
question of &amp;quot;should I write code that is easy to test?&amp;quot;. By now, this discussion
has turned into a &amp;#39;religious war&amp;#39; of sorts. There is no definitive answer to
this question.&lt;/p&gt;
&lt;h2 id="writing-testable-code"&gt;Writing &amp;#39;testable&amp;#39; code&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You shouldn&amp;#39;t write code with your test suite in mind&lt;/strong&gt;. Write code that makes
sense. Write code that is easy to read, and easy to understand. Most of all,
&lt;strong&gt;write code that is maintainable&lt;/strong&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;/p&gt;
&lt;p&gt;You can think of this as a positive feedback loop. Your code becomes easier to
test the &amp;#39;better&amp;#39; code you write. Better in the sense that it&amp;#39;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;/p&gt;
&lt;p&gt;There are exceptions to this rule. Sometimes, &amp;#39;good&amp;#39; code is hard to test.
That&amp;#39;s why you should always write code without your test suite in mind.&lt;/p&gt;
&lt;p&gt;Knowing that your application, as a whole, returns expected results for certain
scenarios isn&amp;#39;t that reassuring. You can never cover all possible combinations
of states in an application.&lt;/p&gt;
&lt;p&gt;Therefore, it&amp;#39;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 id="write-more-unit-tests"&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&amp;#39;t tell you if your
application performs as expected in all situations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You should write integrations tests!&lt;/strong&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;/p&gt;
&lt;p&gt;It&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--mp4"&gt; &lt;video class="attachment__image--sized placeholder" style="--attachment-width: 480; --attachment-height: 270; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRkwAAABXRUJQVlA4IEAAAABwAQCdASoQAAkABABoJYwAAT%2F%2B8ADJdA4O552UuwloY83BgNScfBXDqXSKOAV4ouQTzh3e8URO2RQ901lcAAAA%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #838b82;" autoplay playsinline loop muted poster="https://media.stanko.io/variants/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/giphy.jpg/480w.jpg" width="480" height="270"&gt; &lt;source src="https://media.stanko.io/originals/articles/tips-to-improve-your-tests-414733ecbc4-414733ecbc4/giphy.mp4" type="video/mp4"&gt; &lt;/video&gt; &lt;figcaption class="attachment__caption"&gt;&lt;/figcaption&gt; &lt;/figure&gt;
&lt;h2 id="stubs-and-mocks"&gt;Stubs and mocks&lt;/h2&gt;
&lt;p&gt;I often see people letting their &amp;#39;unit&amp;#39; test call code from a module that isn&amp;#39;t
their module under test. That effectively turns a unit into an integration test.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The basic idea of stubs is to decouple modules from one another&lt;/strong&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&amp;#39;s code, it just returns the desired value then and there.
This is an incredibly powerful tool! &lt;strong&gt;Not only can you decouple modules this
way, but you can control your program&amp;#39;s flow&lt;/strong&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;/p&gt;
&lt;p&gt;Stubs can&amp;#39;t assure that a method got called, which is important if that call
does something &amp;#39;mission critical&amp;#39;. 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&amp;#39;t get called the test
explicitly fails.&lt;/p&gt;
&lt;p&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 id="asserting-vs-mocking"&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;em&gt;We can
differentiate two kinds of method calls — queries and commands&lt;/em&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;/p&gt;
&lt;p&gt;We also differentiate incoming and outgoing calls. &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;/p&gt;
&lt;p&gt;The returned value of outgoing queries should not be asserted. These values will
be asserted in the respective module&amp;#39;s unit test. Outgoing commands should be
mocked. We rely on other module&amp;#39;s commands to work. It&amp;#39;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&amp;#39;s repercussions without causing an
artificial success state.&lt;/p&gt;
&lt;p&gt;The returned value of incoming queries &lt;em&gt;should be&lt;/em&gt; asserted. We need to make
sure that given an input our method returns an expected result. Incoming
commands&amp;#39; repercussions should be asserted for the same reason incoming queries
should be asserted.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Incoming&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;assert return value&lt;/td&gt;
&lt;td&gt;assert side effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Outgoing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;do not assert&lt;/td&gt;
&lt;td&gt;assert message sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&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 id="write-tests-first"&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;/p&gt;
&lt;p&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&amp;#39;s better to write a test suite after the fact.&lt;/p&gt;
&lt;h2 id="conclusion"&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;
&lt;p&gt;Testing shows the presence, not the absence of bugs&lt;/p&gt;
&lt;p&gt;— Edsger W. Dijkstra&lt;/p&gt;
&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: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;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/2400w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 4000; --attachment-height: 2250; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%209%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRlAAAABXRUJQVlA4IEQAAAAQAgCdASoQAAkABABoJZgCdADbpeBtKvYAAP6XTUJ2FCC2%2BZmkscx0B%2FTkNAphtIKRLr%2B%2FDnBsLM4ABnYwqr09OiAAAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #7b768c;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/800w.webp 800w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/1200w.webp 1200w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/1600w.webp 1600w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/2400w.webp 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="The Copenhagen town hall building" loading="lazy" decoding="async" class="attachment__image" width="4000" height="2250" src="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/1200w.jpg" srcset="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/800w.jpg 800w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/1200w.jpg 1200w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/1600w.jpg 1600w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/952-nqgy3pkez3-h7i-7ea.jpeg/2400w.jpg 2400w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;The Copenhagen town hall building&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;s Messenger&lt;/a&gt;,
let alone win first place.&lt;/p&gt;
&lt;h2 id="the-journey-to-copenhagen"&gt;The journey to Copenhagen&lt;/h2&gt;
&lt;p&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&amp;#39;s not forget all the swag
that gets handed out!&lt;/p&gt;
&lt;p&gt;Copenhacks was a new thing, so we didn&amp;#39;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&amp;#39;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;&amp;#39;what could we do?&amp;#39;&lt;/em&gt;, &lt;em&gt;&amp;#39;what would
impress the judges?&amp;#39;&lt;/em&gt;, &lt;em&gt;&amp;#39;what did we have time to implement in 24 hours?&amp;#39;&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;/p&gt;
&lt;p&gt;Our flight to Copenhagen was getting closer and closer and we couldn&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;More importantly, our extension would be a platform on top of which anybody
could build their own extension to Messenger.&lt;/p&gt;
&lt;h2 id="the-hackathon"&gt;The hackathon&lt;/h2&gt;
&lt;p&gt;My friend once said &lt;em&gt;&amp;#39;Hackathons are an interesting social experiment&amp;#39;&lt;/em&gt;. That&amp;#39;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&amp;#39;s when people start getting into arguments and fights. Then
the whole experience turns into a delicate dance of not stepping on each other&amp;#39;s
toes.&lt;/p&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--gif" data-full-src="https://media.stanko.io/originals/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/wcsuebt71hobqp90dnldha.gif"&gt; &lt;picture class="attachment__image--sized" style="--attachment-width: 366; --attachment-height: 206; "&gt;  &lt;img alt="Us playing table-tennis at 3 am" loading="lazy" decoding="async" class="attachment__image" width="366" height="206" src="https://media.stanko.io/originals/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/wcsuebt71hobqp90dnldha.gif"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Us playing table-tennis at 3 am&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&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;/p&gt;
&lt;h2 id="reverse-engineering-messenger"&gt;Reverse engineering Messenger&lt;/h2&gt;
&lt;p&gt;Finding an entry vector in JS applications usually isn&amp;#39;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&amp;#39;s source is split into several
minified files which makes them hard to understand and it&amp;#39;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;/p&gt;
&lt;p&gt;It was obvious that Messenger needs to send and receive data from a server so we
opened up Chrome&amp;#39;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;code&gt;send_messages.php&lt;/code&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;/p&gt;
&lt;p&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&amp;#39;t have a
reference to Messenger&amp;#39;s main process but it has a reference to the module
loader &lt;code&gt;__d&lt;/code&gt; which we then patched to give us a reference to the object we
needed.&lt;/p&gt;
&lt;p&gt;Receiving messages was a different ball game. We couldn&amp;#39;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&amp;#39;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&amp;#39;s CSS class. Then it tries to find a PGP
encrypted and signed message and decrypts it.&lt;/p&gt;
&lt;p&gt;This all sounds easy enough, but it was a really complicated and frustrating
task. We didn&amp;#39;t know what Messenger&amp;#39;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;/p&gt;
&lt;p&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;code&gt;pgp.messenger.com&lt;/code&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;/p&gt;
&lt;figure class="attachment attachment--preview attachment--jpeg" data-full-src="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/700w.jpg"&gt; &lt;picture class="attachment__image--sized placeholder" style="--attachment-width: 700; --attachment-height: 466; --placeholder: url(&amp;quot;data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2011%22%20preserveAspectRatio%3D%22none%22%3E%3Cfilter%20id%3D%22b%22%20color-interpolation-filters%3D%22sRGB%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221%22%2F%3E%3CfeComponentTransfer%3E%3CfeFuncA%20type%3D%22discrete%22%20tableValues%3D%221%201%22%2F%3E%3C%2FfeComponentTransfer%3E%3C%2Ffilter%3E%3Cimage%20width%3D%22100%25%22%20height%3D%22100%25%22%20preserveAspectRatio%3D%22none%22%20filter%3D%22url(%23b)%22%20href%3D%22data%3Aimage%2Fwebp%3Bbase64%2CUklGRm4AAABXRUJQVlA4IGIAAAAwAgCdASoQAAsABABoJQBOgMXSxGb1c%2BHeAAD%2Bqrhk20WAoEJzl%2BJW6haJ%2B7ShDqoyP%2Fq2w1Vr%2BTEzx%2BR92%2F85HHS890Oj%2BN78CLUUw2wo7%2BcmefMkJTvaICWy3EyI3crQAA%3D%3D%22%2F%3E%3C%2Fsvg%3E&amp;quot;); --placeholder-color: #716b61;"&gt; &lt;source type="image/webp" srcset="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/480w.webp 480w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/700w.webp 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;img alt="Warning, hack in progress!" loading="lazy" decoding="async" class="attachment__image" width="700" height="466" src="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/700w.jpg" srcset="https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/480w.jpg 480w, https://media.stanko.io/variants/articles/hacking-privacy-into-facebook-s-messenger-in-24-hours-3da239baf6c5/w_5ag9szej3-e-27k2yya.jpeg/700w.jpg 700w" sizes="(width &gt;= 768px) 1040px, 100vw"&gt; &lt;/picture&gt; &lt;figcaption class="attachment__caption"&gt;Warning, hack in progress!&lt;/figcaption&gt; &lt;/figure&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;After the presentations were over none of us believed that we would win
anything. But that didn&amp;#39;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;/p&gt;
&lt;h2 id="the-anticipation"&gt;The anticipation&lt;/h2&gt;
&lt;p&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&amp;#39;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;/p&gt;
&lt;p&gt;This hackathon was something I will remember, not just because we did something
awesome, but because it was really fun.&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;p&gt;If you are thinking about going to such an event then do it. Don&amp;#39;t hesitate. It
doesn&amp;#39;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&amp;#39;t hesitate. You will change somebody&amp;#39;s
life forever and meet really cool people in the process.&lt;/p&gt;
&lt;h2 id="conclusion--privacy-is-hard"&gt;Conclusion — Privacy is hard&lt;/h2&gt;
&lt;p&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;/p&gt;
&lt;p&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&amp;#39;s stopping the proprietary code from collecting the messages
you exchange and exposing them over a different channel?&lt;/p&gt;
&lt;p&gt;Privacy is hard. It is not something we should take for granted, but something
we should actively try to achieve and protect.&lt;/p&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 Copenhagen

We usually go to a lot of hackathons and coding competitions,...</summary>
    <author>
      <name>Stanko Krtalic Rusendic</name>
      <email>hey@stanko.io</email>
    </author>
  </entry>
</feed>
