<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Elixir - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/elixir/</link><description>Elixir - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Tue, 10 Nov 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Elixir - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/elixir/</link></image><atom:link href="https://tracyatteberry.com/tags/elixir/" rel="self" type="application/rss+xml"/><item><title>How many reactions per second can Speechwave handle?</title><link>https://tracyatteberry.com/posts/sw_performance/</link><pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/sw_performance/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/sw_performance/concert.jpg" referrerpolicy="no-referrer">
            </div><h1 id="how-many-reactions-per-second-can-speechwave-handle">How many reactions per second can Speechwave handle?</h1>
<p>A user recently sent us this question:</p>
<blockquote>
<p>How many reactions can the audience send at once before the emoji display slows down or drops some of them?</p>
</blockquote>
<p>It&rsquo;s a fair question to ask before you put an app in front of a few hundred
people at a conference. We haven&rsquo;t had a reported issue yet, but I realized I
had never actually measured it. So I did, and here are the results.</p>
<p>If you haven&rsquo;t seen it, <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">Speechwave</a> is a live emoji
reaction app for talks. The audience taps emoji on their phones, and a Chrome
extension floats them over the speaker&rsquo;s slides. The speaker gets analytics afterward.</p>
<h2 id="great-expectations">Great expectations</h2>
<p>I already knew from developing the code that each audience member is limited to
one reaction every 3 seconds. So the most any room can send is the number of
people divided by 3, per second. A 300-person room tops out at 100 reactions
per second, and only if everyone taps the instant their button comes back,
<em>and</em> that&rsquo;s if you get 100% room participation.</p>
<p>That gives us our theoretical maximum, but tells us nothing about what the
server does at 100 reactions per second.</p>
<h2 id="what-about-sqlite">What about SQLite?</h2>
<p>I next guessed that SQLite could be a weak spot. Every accepted reaction is
saved to the database, and SQLite has a reputation for being a single-writer
database. However, it turns out SQLite can write far more than a few hundred
rows a second.</p>
<p>Measuring it inside the same container used for the rest of the test, it did
about 7,500 inserts per second. It didn&rsquo;t seem like the database was going to
be out limiting factor.</p>
<h2 id="testing-methods">Testing methods</h2>
<p>Wanting the test to look like production, I built the production Docker image
and ran it locally with the same limits as my Fly machine: one CPU, 512 MB of
memory, and a database pool of 2. Then I wrote a small Node script that
simulates an audience. Each fake person loads the talk page, joins the LiveView
over a websocket, and taps on a timer. A second connection plays the Chrome
extension and times how long a reaction takes to arrive.</p>
<p>There were two gotchas.</p>
<p>First, Docker&rsquo;s <code>--cpus=1</code> limits how much CPU time a container gets, but the
Erlang VM still looked at my laptop and started eight schedulers. On Fly it
would see one. When I pinned the container to a single core, the numbers got a
worse, but a lot more realistic.</p>
<p>Second, <strong>the load generator was the bottleneck</strong>. Every reaction is sent to every
audience phone, so with 800 fake people one Node process had to read about 200,000
messages per second. It couldn&rsquo;t, and that backed things up on the server in a way
real phones wouldn&rsquo;t. I split the generator across several processes and made it
count messages without parsing them.</p>
<h2 id="results">Results</h2>
<p>I held the audience at a fixed size and raised the total number of reactions per
second until something gave.</p>
<table>
	<thead>
			<tr>
					<th>People</th>
					<th>Reactions/s</th>
					<th>CPU</th>
					<th>Delivered</th>
					<th>Delay (median / p95)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>400</td>
					<td>60</td>
					<td>68%</td>
					<td>100%</td>
					<td>4 / 18 ms</td>
			</tr>
			<tr>
					<td>400</td>
					<td>100</td>
					<td>99%</td>
					<td>100%</td>
					<td>60 / 116 ms</td>
			</tr>
			<tr>
					<td>400</td>
					<td>125</td>
					<td>86%</td>
					<td>84%</td>
					<td>24 / 430 ms</td>
			</tr>
			<tr>
					<td>800</td>
					<td>25</td>
					<td>63%</td>
					<td>100%</td>
					<td>3 / 88 ms</td>
			</tr>
			<tr>
					<td>800</td>
					<td>45</td>
					<td>100%</td>
					<td>100%</td>
					<td>76 / 251 ms</td>
			</tr>
			<tr>
					<td>800</td>
					<td>60</td>
					<td>100%</td>
					<td>82%</td>
					<td>43 / 1,495 ms</td>
			</tr>
	</tbody>
</table>
<p>Of course, the answer depends on how many people are in the room.
With 400 people, things slow down at about 100 reactions per second and start
dropping soon after. With 800 people, the same thing happens at about 45.</p>
<h2 id="showing-everybody-everything">Showing everybody everything</h2>
<p>Here&rsquo;s the part I hadn&rsquo;t initially thought about. The audience page also shows
the floating emoji, so that people can see their reaction appear. That means
every reaction is pushed to every connected phone, not only to the speaker&rsquo;s
laptop.</p>
<p>Here&rsquo;s the handler from the audience LiveView:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_info</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="p">%</span><span class="nc">Phoenix.Socket.Broadcast</span><span class="p">{</span><span class="ss">event</span><span class="p">:</span> <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span> <span class="ss">payload</span><span class="p">:</span> <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">}},</span>
</span></span><span class="line"><span class="cl">      <span class="n">socket</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">push_event</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">})}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Every audience member runs that for every reaction. So the work per second is the
number of people times the number of reactions per second, which grows with the
square of the room. In my runs one core topped out at about 35,000 to 40,000 of
those pushes per second, and both of my break points were right there:</p>
<ul>
<li>400 people × 100 reactions/s = 40,000</li>
<li>800 people × 45 reactions/s = 36,000</li>
</ul>
<p>That gives us a rule of thumb: <strong>total reactions per second should stay under about
35,000 divided by the number of people</strong>. A room of 1,000 can handle about 35 reactions
per second. A room of 200 can handle about 175, though it can&rsquo;t reach that, because the
3-second limit caps it at about 67.</p>
<p>Real audiences don&rsquo;t tap constantly. If each person reacts about once every 30
seconds, 800 people send 27 reactions per second, and that ran fine at 55% CPU.
Raise it to 1,200 people at the same pace and the core saturates. Obviously, we
could handle higher loads if we assume less than 100% participation.</p>
<h2 id="what-failure-mode-looks-like">What failure mode looks like</h2>
<p>We want things to degrade gracefully. When the core is saturated, the database insert
inside someone&rsquo;s LiveView waits too long for a pool connection and raises. That
crashes the process, so the reaction is lost, and the phone has to reconnect.
While it&rsquo;s not ideal, the audience is unlikely to notice a few lost reactions.
However&hellip;</p>
<p>Memory was the other ceiling. Each connected person costs about 0.23 MB on top of a
baseline around 160 MB. A 3,200-person run was killed for running out of memory.
That&rsquo;s not something we&rsquo;ve seen in production yet, but it wouldn&rsquo;t be great.</p>
<h2 id="how-we-can-make-it-even-better">How we can make it even better</h2>
<p>The obvious fixes are to batch the pushes to audience phones (one message every
quarter second instead of one per reaction), to batch the database writes, and
to catch an insert failure so one timeout doesn&rsquo;t take a page down with it. The
first one attacks the primary issue. Of course, if we need to, we can always
scale our servers at Fly as well.</p>
<p>Caveats: These were single runs, so the edges are fuzzy: one 800-person run
lost 5% of its reactions at a rate where a harder run lost none, so there&rsquo;s
some variance here. Fly&rsquo;s shared CPU can be throttled harder than a pinned core
on my laptop, so the real limits may be lower. Also, I only measured the server.
Whether the emoji on the speaker&rsquo;s screen keep up at these rates is a separate
test. For <em>really</em> large talks we may need to throttle/summarize the reaction
stream going to the speaker&rsquo;s broswer extension.</p>
<h2 id="so-in-summary">So in summary&hellip;</h2>
<p>For a typical talk with a few hundred participants, life should be good. For a
thousand active particpants (congratulations!), reactions might start to drop
above roughly 35 reactions per second across the whole audience, which is what
you&rsquo;d see if everyone tapped about once every 30 seconds and then something
exciting happened in the talk. That&rsquo;s a good problem to have, and now we know
where the edge is. Even better, we know how to push the edge out futher if we
need to.</p>
]]></description></item><item><title>Why I built Speechwave in Elixir (as a Rails developer)</title><link>https://tracyatteberry.com/posts/why-elixir/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/why-elixir/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/why-elixir/swhero.png" referrerpolicy="no-referrer">
            </div><h1 id="why-i-built-speechwave-in-elixir-as-a-rails-developer">Why I built Speechwave in Elixir (as a Rails developer)</h1>
<p>I&rsquo;ve been developing with Ruby on Rails for a long time. If you had asked me to bet on
what I&rsquo;d build my next web app with, I would have bet on Rails.</p>
<p>Then I started building <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">Speechwave</a>, a live emoji reaction app
for conference talks, and I wrote it in Elixir instead</p>
<p>This isn&rsquo;t a &ldquo;Rails is dead&rdquo; post. It&rsquo;s still a great choice for lots of
things. This is about what this particular app needed, why Elixir turned out to
a great choice, and why the hosting bill is about as small as a bill can get. If you&rsquo;re an Elixir
developer, some of this may be familiar.</p>
<h2 id="what-speechwave-does">What Speechwave does</h2>
<p>Attendees scan a QR code at a talk and are sent to a page of emoji buttons. They
tap one. A Chrome extension on the speaker&rsquo;s laptop receives that reaction and
floats the emoji over the speaker&rsquo;s Google Slides in real time. After the talk,
the speaker can see which slides got which reactions.</p>
<p>Look at that from a server&rsquo;s point of view. It&rsquo;s a lot of small, short-lived,
simultaneous things:</p>
<ul>
<li>Dozens (or hundreds) of phones holding open WebSocket connections</li>
<li>Each tap needs to be rate limited, optionally saved, and fanned out to
everyone who cares</li>
<li>The speaker&rsquo;s extension holds one more long-lived connection, and it isn&rsquo;t
even a web page</li>
<li>The whole thing is quiet for days, then very busy for 45 minutes</li>
</ul>
<p>That last point will matter quite a bit when we get to the hosting
section.</p>
<h2 id="why-it-works-the-three-things-i-would-have-had-to-do-in-rails">Why it works: the three things I would have had to do in Rails</h2>
<p>I don&rsquo;t want to oversell this. Rails can do real-time. Action Cable exists,
and Rails keeps improving. But in Rails I would have been assembling
pieces. In Phoenix, the pieces come in the box and they were designed together.</p>
<h3 id="1-a-message-bus-that-doesnt-care-whos-listening">1. A message bus that doesn&rsquo;t care who&rsquo;s listening</h3>
<p>Phoenix PubSub lets any process broadcast to a topic and any other process
subscribe to it. The attendee&rsquo;s LiveView doesn&rsquo;t know the Chrome extension
exists. After the rate limit check (and saving the reaction, if a session is
recording), it just broadcasts. Here&rsquo;s the handler from <code>talk_live.ex</code>, with the
persistence code trimmed out:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">session_id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...save the reaction if a session is recording...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The extension&rsquo;s channel is subscribed to the same topic, so it gets the
message. If it add a third kind of listener tomorrow, say a live dashboard,
nothing in that function changes.</p>
<h3 id="2-liveview-for-the-attendee-page">2. LiveView for the attendee page</h3>
<p>The attendee page is a LiveView, so we got WebSocket-backed interactivity without
building a single-page app and without maintaining a separate JavaScript
frontend. The UI is a function of server state, and Phoenix handles keeping the
browser in sync. For an app where nearly every interesting event is &ldquo;something
happened on the server, tell the clients,&rdquo; that model is a very good match.</p>
<h3 id="3-cheap-isolated-processes-for-everything-else">3. Cheap, isolated processes for everything else</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table (an in-memory
key/value store built into the runtime). Reads don&rsquo;t queue behind the
GenServer, so a room full of people tapping at once doesn&rsquo;t turn into a
traffic jam. It&rsquo;s a few dozen lines and there is no Redis anywhere.</p>
<p>Presence, which tracks how many extensions are connected to a talk so we can
enforce plan limits, is also built in. So is the supervision tree that restarts
any piece that crashes without taking down the rest. Here&rsquo;s the top level of
the tree from <code>application.ex</code>, trimmed a bit for space:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">children</span> <span class="o">=</span>
</span></span><span class="line"><span class="cl">  <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.Repo</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="nc">Phoenix.PubSub</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.PubSub</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.RateLimiter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.AuthThrottle</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...a few more throttles and reapers...</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Presence</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span> <span class="o">++</span> <span class="n">backup_children</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">opts</span> <span class="o">=</span> <span class="p">[</span><span class="ss">strategy</span><span class="p">:</span> <span class="ss">:one_for_one</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.Supervisor</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nc">Supervisor</span><span class="o">.</span><span class="n">start_link</span><span class="p">(</span><span class="n">children</span><span class="p">,</span> <span class="n">opts</span><span class="p">)</span></span></span></code></pre></div></div>
<p>I wrote down what happens when each child crashes (the rate limiter loses its
cooldown state, Presence resets its count, and so on) and for this app every
answer is &ldquo;acceptable, by design.&rdquo; Thinking about failure that explicitly is
something the platform nudges you toward. In a Rails apps, I mostly find out
what a crash does when it happens.</p>
<h2 id="the-hosting-bill">The hosting bill</h2>
<p>This is the part I was the most excited about, and why I&rsquo;m writing this
post in the first place.</p>
<p>Speechwave runs on <a href="https://fly.io" target="_blank" rel="noopener noreffer ">Fly.io</a>, and here are the essential bits
configuration:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-toml">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;512mb&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpu_kind</span> <span class="p">=</span> <span class="s1">&#39;shared&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpus</span> <span class="p">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span></span></span></code></pre></div></div>
<p>That&rsquo;s one shared CPU and 512 MB of memory. When nobody is using the app, the
machine suspends. When someone scans a QR code, it wakes up.</p>
<p>Three things make that work, and Elixir is part of each of them.</p>
<p><strong>The runtime is frugal.</strong> Because the BEAM runs lightweight processes instead
of OS threads, one small machine can hold a lot of open connections. I&rsquo;ve
configured the app to allow up to 1,000 concurrent connections on that single
512 MB box. For context, my realistic target is a few conference rooms, not a
stadium.</p>
<p><strong>One process serves everything.</strong> The reaction fan-out, the LiveView pages,
the Channel for the extension, the rate limiter, the presence tracking, and the
scheduled backups all run inside a single Elixir application. I don&rsquo;t need a
separate Redis for pub/sub, a separate worker process for background jobs, or a
separate WebSocket server. Fewer moving parts means fewer things to pay for and
fewer things to keep alive.</p>
<p><strong>SQLite is enough.</strong> The first version of this app used Fly&rsquo;s managed
Postgres. I&rsquo;ve since moved to SQLite on a Fly volume, with Ecto&rsquo;s SQLite
adapter and a pool size of 2. Reactions are small writes, the data volume is
tiny, and a few talk-shaped workloads doesn&rsquo;t need a database server.</p>
<p>That does mean I own backups. A GenServer takes a snapshot every hour using
SQLite&rsquo;s <code>VACUUM INTO</code> (which gives a consistent copy of a live database,
unlike copying the file) and uploads it to an S3-compatible object storage. It&rsquo;s
about a hundred lines of Elixir, it&rsquo;s supervised like everything else, and a
failed backup logs an error instead of crashing anything. If you want the
details, the <a href="https://docs.speechwave.live/dev/explainer/db-backup.html" target="_blank" rel="noopener noreffer ">codebase explainer</a>
walks through it.</p>
<p>Put those together and the cost is tiny. Fly waives invoices under $5, and only
last month did my bill creep above that to $5.49, just a little over the
line. Usage of the S3 object store at Tigris has stayed within its free
allowances.</p>
<p>I want to be careful about what I&rsquo;m claiming. That number reflects low traffic
on a young app. I haven&rsquo;t load tested a 5,000-person keynote, and I&rsquo;m not
claiming this configuration handles one. What I can say is that the floor is
very low, and the architecture doesn&rsquo;t force me to pay for a fleet of
services before I&rsquo;ve earned a paying user.</p>
<h2 id="could-i-have-done-this-in-rails">Could I have done this in Rails?</h2>
<p>Probably, yes. Rails 8 made SQLite a first-class citizen, and Solid Cable
removes the Redis requirement for Action Cable. I&rsquo;m not going to pretend that
Rails can&rsquo;t get to a similar place. A fair comparison, though, is about how
much I had to assemble and tune to get there, and about how comfortable the
result is at idle and at peak.</p>
<p>For this specific shape of app, many long-lived connections, constant
broadcast, tiny writes, spiky traffic, Phoenix gave me the right design
without much negotiation. In Rails I would have been making deliberate choices
to approximate it.</p>
<p>I&rsquo;ll also say what I did <strong>not</strong> get for free. Rails has an enormous ecosystem
of gems that solve the boring parts of an app. In Elixir I wrote more of the
glue myself. Authentication is a good example: magic links and OAuth took more
work (Phoenix&rsquo;s generator got me started, and a library called Assent handles
the OAuth side). If this had been a plain CRUD app with admin screens, Rails
may have been faster to build, and I may have shipped it sooner.</p>
<h2 id="what-learning-elixir-was-like-for-a-ruby-developer">What learning Elixir was like for a Ruby developer</h2>
<p>Easier than I expected, honestly. The syntax is friendly to Rubyists, the
tooling (<code>mix</code>, <code>iex</code>, ExUnit) feels familiar, and the community writes really
good documentation. The adjustments that took some effort were more conceptual:</p>
<ul>
<li>Data is immutable, so you transform values instead of mutating objects.</li>
<li>Pattern matching shows up everywhere, and it replaces a surprising amount of
<code>if</code> and <code>case</code> logic.</li>
<li>You think in processes, which is a new mental model, but a pleasant one once
it clicks.</li>
</ul>
<p>If you&rsquo;ve used Ruby 3&rsquo;s <code>case/in</code>, pattern matching won&rsquo;t be a brand new idea.
You can destructure hashes and arrays, bind variables, and pin values with <code>^</code>:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">case</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span> <span class="ss">body</span><span class="p">:}</span>
</span></span><span class="line"><span class="cl">  <span class="n">render</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">404</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">not_found</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The difference is how deep it goes. In Ruby, pattern matching is a feature you
opt into with <code>case/in</code>, and most Ruby code still uses <code>if</code>, <code>case/when</code>, and
guard clauses. In Elixir it&rsquo;s foundational. Even <code>=</code> is a match operator, not
just assignment. Most importantly, you can match right in a function&rsquo;s
arguments, so each clause of a function handles one shape of input. Speechwave&rsquo;s
Channel has two <code>join</code> functions, for example (from <code>reaction_channel.ex</code>, with
the first one&rsquo;s body trimmed):</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">slug</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;api_key&#34;</span> <span class="o">=&gt;</span> <span class="n">api_key</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...check the talk, the user, and the plan limits...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">_slug</span><span class="p">,</span> <span class="n">_params</span><span class="p">,</span> <span class="n">_socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The first clause only matches when the topic starts with <code>&quot;reactions:&quot;</code> and the
params include an <code>api_key</code>. Anything else falls through to the second clause,
which rejects it. No <code>if</code> required.</p>
<p>Ruby methods have one body, so you branch inside it. Elixir functions can have
many clauses, and the branching happens before your code even runs. That&rsquo;s the
part that changed how code was structured, and it&rsquo;s why pattern matching feels
more like a way of working than a convenient syntax.</p>
<p>Here&rsquo;s an example. The Channel join for the Chrome extension is a chain of
four checks, and each failure has its own reason. This is the body of that first
<code>join</code> clause, minus a couple of setup lines inside the <code>do</code> block:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">with</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="p">%</span><span class="nc">Talks.Talk</span><span class="p">{}</span> <span class="o">=</span> <span class="n">talk</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="nc">Talks</span><span class="o">.</span><span class="n">get_talk_by_slug</span><span class="p">(</span><span class="n">slug</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="p">%</span><span class="nc">Accounts.User</span><span class="p">{}</span> <span class="o">=</span> <span class="n">user</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="nc">Accounts</span><span class="o">.</span><span class="n">get_user_by_api_key</span><span class="p">(</span><span class="n">api_key</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">true</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="n">talk</span><span class="o">.</span><span class="n">user_id</span> <span class="o">==</span> <span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="ss">:ok</span><span class="p">}</span> <span class="o">&lt;-</span>
</span></span><span class="line"><span class="cl">       <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nc">Plans</span><span class="o">.</span><span class="n">check</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">          <span class="ss">:max_participants</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">user</span><span class="o">.</span><span class="n">plan</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nc">Presence</span><span class="o">.</span><span class="n">list</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="n">map_size</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">)}</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...subscribe to disconnect events, schedule presence tracking...</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">join_payload</span><span class="p">(</span><span class="n">user</span><span class="p">),</span> <span class="n">assign</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="ss">talk</span><span class="p">:</span> <span class="n">talk</span><span class="p">,</span> <span class="ss">user</span><span class="p">:</span> <span class="n">user</span><span class="p">)}</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;not_found&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">false</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="ss">:limit_reached</span><span class="p">}}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;capacity_reached&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>I might have written that as a pile of guard clauses in Ruby. This version
reads top to bottom as a list of requirements, and the <code>else</code> block is a
table of what goes wrong.</p>
<p>As an aid to learning I had AI help me write a <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby
developers</a>, drawing the examples
from the Speechwave codebase. If you&rsquo;re coming from the Rails side, that&rsquo;s an
interesting place to start.</p>
<h2 id="what-id-tell-another-rails-developer">What I&rsquo;d tell another Rails developer</h2>
<p>If your app is mostly forms, tables, and background jobs, stay with Rails. You&rsquo;ll
move faster and you&rsquo;ll have more gems to lean on.</p>
<p>If your app is mostly &ldquo;lots of people connected at once, and something
happening to all of them in real time,&rdquo; look hard at Phoenix. The runtime and
the framework were built for exactly that, and you may find, as I did, that the
infrastructure shrinks down to a single small machine.</p>
<p>And if you&rsquo;re already an Elixir developer: I&rsquo;d love your feedback on the
codebase. It&rsquo;s open, it&rsquo;s about 6,800 lines of application code, and I&rsquo;m sure
there are idioms I&rsquo;m still getting wrong.</p>
<ul>
<li>Try Speechwave: <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">speechwave.live</a></li>
<li>Read the code: <a href="https://github.com/speechwave-live/speechwave" target="_blank" rel="noopener noreffer ">github.com/speechwave-live/speechwave</a></li>
<li>Tour the internals: <a href="https://docs.speechwave.live/dev/explainer/index.html" target="_blank" rel="noopener noreffer ">codebase explainer</a></li>
<li>Peek at the AI-assisted <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby developers</a></li>
</ul>
]]></description></item><item><title>Documentation next: Habits that survive AI-assisted development</title><link>https://tracyatteberry.com/posts/documentation_next/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/documentation_next/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/documentation_next/hero.jpg" referrerpolicy="no-referrer">
            </div><p>AI lets you ship faster than ever. That&rsquo;s great. But six months later, you&rsquo;re
staring at your own codebase wondering who wrote this. Why is this service
doing that? Why did you pick this library over the obvious one? What were you
thinking?</p>
<p>The problem usually isn&rsquo;t the code. It&rsquo;s that AI accelerates the building
without capturing the thinking. You evaluate options, make tradeoffs, reject
approaches, and move on. The decision happens in a chat window and then it&rsquo;s
gone. What&rsquo;s left is code without context, and that debt (a form of cognitive
debt) compounds quietly until it becomes a real problem.</p>
<p>Though it might help, the best fix isn&rsquo;t to slow down. It&rsquo;s to build a few
lightweight documentation habits that will hold up in an AI-assisted workflow.</p>
<h2 id="the-new-documentation-problem">The new documentation problem</h2>
<p>Traditional documentation advice assumes you understand everything you&rsquo;re
writing. You wrote the code, you know why it works the way it does, you just
need to write it down. AI-assisted development changes that assumption in a
couple of ways.</p>
<p>First, you move faster. When you&rsquo;re evaluating three AI-generated
implementations in ten minutes instead of writing one over an hour, there&rsquo;s
almost no natural pause where documentation feels like the obvious next step.
The decision-making is compressed, and so is the documentation.</p>
<p>Second, git history gets less useful. It still tells you what changed, but it
doesn&rsquo;t tell you that you picked this background job library because the other
one had a deployment constraint you needed to avoid. It doesn&rsquo;t tell you which
of four Stripe webhook implementations the AI suggested, or why you picked that
one. It doesn&rsquo;t tell you that you almost went a completely different direction.</p>
<p>The gap is context. And that&rsquo;s exactly what these habits are designed to
capture.</p>
<h2 id="document-decisions-not-implementations">Document decisions, not implementations</h2>
<p>The highest-leverage documentation habit is writing down why, not what. Code
already shows what. What it can&rsquo;t show is the thinking that produced it.</p>
<p>Keep a <code>docs/decisions.md</code> file. No formal template required. No ADR ceremony.
Just a running list of decisions, what options you considered, what you ruled
out and why, and what you landed on. A few sentences per decision is enough.</p>
<p>Some examples of the kind of thing worth capturing:</p>
<ul>
<li>Why you chose Sidekiq over Solid Queue (maybe it was a deployment constraint,
maybe it was familiarity, maybe you tried Solid Queue and hit a weird edge case)</li>
<li>Which Stripe webhook implementation you kept after the AI suggested three
different approaches</li>
<li>Why the data model is shaped the way it is instead of the more obvious
structure you considered first</li>
</ul>
<p>This is especially useful in AI-assisted work because the evaluation often
happens fast. You look at a few options, pick one, and move on. Without a
quick note somewhere, that reasoning is gone the moment you close the chat.</p>
<h2 id="the-daily-scratchpad">The daily scratchpad</h2>
<p>A scratchpad is not a journal. It&rsquo;s not a changelog. It&rsquo;s a place to think out
loud while you&rsquo;re working.</p>
<p>Drop a file at <code>tmp/scratch.md</code> (some people call it <code>journal.md</code> or
<code>dev-journal.md</code>) and use it to capture rationale in the moment. What did you
try? What didn&rsquo;t work? How did you arrive at this solution? What were you
unsure about?</p>
<p>It doesn&rsquo;t need to be clean. It doesn&rsquo;t need to be organized. It just needs to
exist.</p>
<p>Git history will tell future you that something changed on a Tuesday in March.
The scratchpad will tell future you that you were choosing between two
approaches, that one of them had a subtle concurrency problem you figured out
mid-afternoon, and that the solution you landed on was a compromise you felt
okay about. That&rsquo;s the stuff that can really help when you return to a project
three months later and can&rsquo;t quite remember what you were even trying to do.</p>
<h2 id="let-ai-explain-what-it-built">Let AI explain what it built</h2>
<p>Sometimes you ship code you don&rsquo;t fully understand yet. That&rsquo;s not a great
position to be in, but it&rsquo;s a real one, especially when you&rsquo;re learning a new
language or framework alongside building something.</p>
<p>When that happens, ask the AI to explain what it just built.</p>
<p>Recently I was building a new SaaS product in Elixir while still actively
learning the language. The initial architecture involved OTP supervision trees,
GenServers, and some patterns that weren&rsquo;t immediately obvious (to me). Rather
than hoping the understanding would come later, asking the AI to write an
<code>explainer.md</code> for the architecture made the whole thing legible. Later, an
<code>explainer.html</code> with diagrams and a walkthrough made it even better.</p>
<p>You can do the same thing with an existing codebase you&rsquo;re jumping into for the
first time. Drop the relevant files into context and ask for an explainer. It&rsquo;s
faster than reverse-engineering the code yourself, and it gives you something
you can update and refer back to as your understanding deepens.</p>
<p>My explainers live in <code>docs/explainers/</code>. They&rsquo;re not precious documents. They
can be rough, they can be wrong in places, and they should be updated as you
learn more. But having them at all is the difference between a codebase that
feels like yours and one that always feels a little foreign.</p>
<h2 id="keep-your-readme-human">Keep your README human</h2>
<p>AI tooling has introduced a temptation to stuff READMEs with prompts, agent
instructions, and notes about which parts were AI-generated. Resist it.</p>
<p>The README is for humans. What is this project? How do I get it running? Where
is the rest of the documentation? How do I contribute?</p>
<p>AI-specific context belongs elsewhere. Agent instructions and project rules for
your AI assistant go in <code>AGENTS.md</code> (if you use Claude, a <code>CLAUDE.md</code> symlink
pointing to it is handy). Plans and specs go in <code>docs/plans/</code> and
<code>docs/specs/</code>. Decisions go in <code>docs/decisions.md</code>. Explainers go in
<code>docs/explainers/</code>.</p>
<p>The README is the front door. Keep it welcoming and keep it human.</p>
<h2 id="own-the-code-all-of-it">Own the code, all of it</h2>
<p>You don&rsquo;t need to mark which lines an AI wrote.</p>
<p>I know that might feel counterintuitive. There&rsquo;s a reasonable instinct to be
transparent about your process. But there&rsquo;s a difference between transparency
and hedging. Marking code as AI-generated is a subtle way of distancing
yourself from it, as if to say: if this breaks, that&rsquo;s not entirely on me. But
that&rsquo;s not how accountability works.</p>
<p>You are responsible for every line of code you ship, regardless of how it was
produced. If you don&rsquo;t understand the code, you shouldn&rsquo;t ship it. If you&rsquo;re
not sure you understand it, that&rsquo;s what explainers are for.</p>
<p>The middle ground is being transparent about your development process at a
high level. It&rsquo;s no secret that most developers use AI assistance now, and
saying so in your docs or your README is great. That&rsquo;s transparency. What&rsquo;s
different is using it as a hedge. The tool you used to write the code doesn&rsquo;t
change your responsibility for the result.</p>
<h2 id="the-loop">The loop</h2>
<p>Good documentation helps you understand your codebase. Understanding your
codebase means you can give the AI better context when you&rsquo;re building something
new. Better context (human and AI) produces code that really fits the existing
architecture. Code that fits is easier to explain and document. Round and
round.</p>
<p>None of these habits are heavy. A few sentences in <code>decisions.md</code>. A rough note
in <code>scratch.md</code>. An explainer file when you&rsquo;re in unfamiliar territory. A
README that stays focused on humans.</p>
<p>The payoff is that six months from now, when you come back to this project, it
feels like yours. You remember what you were thinking. You know why things are
the way they are. And you can keep building without spending the first week
just figuring out what past-you was up to.</p>
<h2 id="bonus-automate-it-with-a-post-merge-hook">Bonus: automate it with a post-merge hook</h2>
<p>All of these habits share a common enemy: you have to remember to do them.
Remembering is fine when you&rsquo;re focused, but documentation is usually the thing
that slips when you&rsquo;re rushing to ship.</p>
<p>Here&rsquo;s a way to take the remembering out of it. A git <code>post-merge</code> hook fires
automatically after every merge. You can use it to shell out to Claude Code in
non-interactive mode and ask it to review and update your docs in light of what
just changed.</p>
<p>The hook grabs the merge diff, hands it to Claude along with a focused prompt,
and lets Claude update <code>README.md</code>, <code>docs/decisions.md</code>, any relevant explainers,
and product docs. Changes are left unstaged so you can review them before
committing. Nothing gets force-pushed. Nothing gets auto-committed. You stay
in control.</p>
<p>Here&rsquo;s the script:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># After merging to main, asks Claude Code to review and update documentation.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Changes are left unstaged for your review.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Setup:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   cp post-merge .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   chmod +x .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Requires Claude Code CLI to be installed and authenticated.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Only run on main branch</span>
</span></span><span class="line"><span class="cl"><span class="nv">CURRENT_BRANCH</span><span class="o">=</span><span class="k">$(</span>git symbolic-ref --short HEAD<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$CURRENT_BRANCH</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;main&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;📝 Checking documentation after merge to main...&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Capture what changed in the merge so Claude has context</span>
</span></span><span class="line"><span class="cl"><span class="nv">DIFF</span><span class="o">=</span><span class="k">$(</span>git diff ORIG_HEAD..HEAD --stat<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build the prompt</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROMPT</span><span class="o">=</span><span class="s2">&#34;A feature branch was just merged to main. Here is a summary of what changed:
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="nv">$DIFF</span><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Please review the following documentation and update anything that is out of date
</span></span></span><span class="line"><span class="cl"><span class="s2">or missing given the changes above:
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">- README.md
</span></span></span><span class="line"><span class="cl"><span class="s2">- docs/decisions.md (add an entry if a significant architectural decision was made)
</span></span></span><span class="line"><span class="cl"><span class="s2">- Any relevant files in docs/explainers/
</span></span></span><span class="line"><span class="cl"><span class="s2">- Any relevant product documentation in docs/
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Do not modify AGENTS.md or any files in docs/plans/ or docs/specs/.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">After making changes, print a brief summary of what you updated and why.
</span></span></span><span class="line"><span class="cl"><span class="s2">If nothing needed updating, say so.&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Claude Code in non-interactive mode with read/write permissions only</span>
</span></span><span class="line"><span class="cl">claude -p <span class="s2">&#34;</span><span class="nv">$PROMPT</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --allowedTools <span class="s2">&#34;Read&#34;</span> <span class="s2">&#34;Write&#34;</span> <span class="s2">&#34;Glob&#34;</span> <span class="s2">&#34;Grep&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --max-turns <span class="m">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Show which files were changed (if any)</span>
</span></span><span class="line"><span class="cl"><span class="nv">DOC_CHANGES</span><span class="o">=</span><span class="k">$(</span>git diff --name-only -- README.md <span class="s1">&#39;docs/**&#39;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$DOC_CHANGES</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;✅ Documentation updated. Review changes before committing:&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$DOC_CHANGES</span><span class="s2">&#34;</span> <span class="p">|</span> sed <span class="s1">&#39;s/^/   /&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;   Run &#39;git diff&#39; to review, then &#39;git add&#39; and &#39;git commit&#39; when ready.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;✅ No documentation changes were necessary.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div>
<p>Download here: <a href="./post-merge" rel="">post-merge</a></p>
<p>To install it, copy the script to <code>.git/hooks/post-merge</code> and make it
executable with <code>chmod +x .git/hooks/post-merge</code>. Since <code>.git/</code> isn&rsquo;t tracked
by git, you might want to keep the canonical copy in a <code>hooks/</code> directory in
your repo and add a setup step to your README.</p>
<p>A few things worth noting about the script:</p>
<p>The <code>--allowedTools</code> flag scopes Claude to read and write only. It can&rsquo;t run
arbitrary bash commands, can&rsquo;t touch your git history, and can&rsquo;t do anything
outside the files you&rsquo;ve pointed it at. Keeping the tool surface small is good
hygiene for any automated Claude Code invocation.</p>
<p>The <code>--max-turns 10</code> flag is a circuit breaker. It caps how many agentic turns
Claude can take so a single post-merge hook can&rsquo;t spin forever if something
unexpected happens.</p>
<p>The prompt explicitly excludes <code>AGENTS.md</code>, <code>docs/plans/</code>, and <code>docs/specs/</code>.
You don&rsquo;t want a merge hook rewriting your agent instructions or your design
plans. Keep the scope tight.</p>
<p>The <code>--max-turns</code> flag is a circuit breaker, but may need to be tuned based on
how much the amount of work that needs to be done. If the circuit breaker is
hit, the hook will exit with an error and leave the finished changes unstaged
for manual review.</p>
<p>This is a starting point, not a finished product. Your project&rsquo;s doc structure
will differ, and you&rsquo;ll want to tune the prompt to match. But the pattern holds:
capture the diff, give Claude focused scope, leave changes unstaged, and let the
human decide what to commit.</p>
]]></description></item><item><title>Cognitive debt: A personal story and practical advice</title><link>https://tracyatteberry.com/posts/cognitive_debt/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/cognitive_debt/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/cognitive_debt/cognitive_debt.png" referrerpolicy="no-referrer">
            </div><p>A few days ago I built a tool called JoyConf: a real-time audience feedback
system that lets speakers see emoji reactions floating up in the corner of
their presentation while they&rsquo;re talking. It was a fun, simple idea and I was
pretty excited about it.</p>
<p>I built it in Elixir and Phoenix LiveView, which was a deliberate choice. I
mostly write Ruby these days, but this project felt like a good excuse to dig
into Elixir and LiveView. Learn something new, build something useful. Two birds,
one stone.</p>
<p>I drove the overall design and implementation planning, did active code review,
and contributed everywhere I could. But for the Elixir and LiveView specifics,
I leaned heavily on Claude. The syntax, architecture decisions, and debugging
were Claude&rsquo;s domain, because I simply didn&rsquo;t know enough yet to own them. The
tool worked and it <em>seemed</em> to work well. But when I got to the end and looked at
the codebase, I realized I didn&rsquo;t really understand the parts Claude had built.
I had reviewed the code as carefully as I could, but reviewing code in a language
you don&rsquo;t know, implementing an unfamiliar architecture, only gets you so far.
The understanding of those pieces had mostly stayed with Claude.</p>
<p>That&rsquo;s cognitive debt. And LLMs are very good at generating it.</p>
<h2 id="what-cognitive-debt-actually-is">What cognitive debt actually is</h2>
<p>Cognitive debt accumulates when you defer the thinking that should happen now.
It&rsquo;s different from technical debt, which is about the code itself (shortcuts
taken, tests skipped, abstractions that didn&rsquo;t quite work out). Cognitive debt is
about the reasoning that never happened. The mental model that never got built.
The decision that got made without being understood.</p>
<p>Like financial debt, it doesn&rsquo;t feel like much at first. You&rsquo;re moving fast,
things are working, you&rsquo;re shipping. The bill comes later, when you need to
debug something you can&rsquo;t reason about, extend a system you don&rsquo;t understand,
or explain a decision you never actually made. And to be clear, cognitive debt
has been around long before LLMs, LLMs just magnify the problem.</p>
<h2 id="llms-make-this-disturbingly-easy">LLMs make this disturbingly easy</h2>
<p>LLM-generated code is mostly right. That&rsquo;s what makes it dangerous.</p>
<p>If the code were obviously wrong, you&rsquo;d catch it. You&rsquo;d dig in, figure out what
went wrong, learn something in the process. But LLM output is usually plausible,
often correct, and just coherent enough that it passes the vibe check. You run
the tests. They pass. You move on. The mental model of how it works never gets
built, because you never needed it&hellip; until you do.</p>
<p>There&rsquo;s a specific failure mode worth naming here. Using an LLM to move faster
on things you understand is <em>leverage</em>. Using it to skip understanding altogether
is <em>debt</em>. Those feel identical in the short term. Both result in code getting
written. One leaves you with understanding you can build on; the other leaves you
with output you&rsquo;re stuck with.</p>
<p>And it catches everyone. Junior developers accept LLM output because they don&rsquo;t
know enough to question it. Senior developers accept it because they had a
hundred PRs today and the code looks fine, so they assume it is fine. Both skip
the reasoning step. The result is a codebase full of decisions nobody on the
team can actually defend.</p>
<h2 id="back-to-joyconf">Back to JoyConf</h2>
<p>When I realized I&rsquo;d built something I didn&rsquo;t fully understand, I asked Claude to
write me an explainer document. Not a summary, but an actual explanation of the
architecture, the key concepts, why certain decisions were made, how the pieces
fit together. Something I could read, learn from, and come back to later.</p>
<p>It wasn&rsquo;t a magic pill. I was starting from near zero with Elixir and LiveView,
so one document didn&rsquo;t make me an expert. But it meaningfully closed the gap. I
understood the code better than I did before. I had something to refer back to.
And I started to feel like the codebase was actually mine.</p>
<p>That experience shaped how I think about using LLMs for coding. The tool works
fine. How you engage with it makes all the difference.</p>
<h2 id="practical-ways-to-keep-the-debt-in-check">Practical ways to keep the debt in check</h2>
<p><strong>Ask for explanations before you accept the code.</strong> Don&rsquo;t just run it. Ask the
LLM to walk you through what it did and why. This takes an extra minute and
catches a surprising number of cases where the code is technically correct but
built on assumptions you don&rsquo;t share.</p>
<p><strong>Ask for an explainer document for bigger decisions.</strong> Architecture choices,
non-obvious patterns, anything you&rsquo;re going to need to live with for a while:
ask the LLM to write it up in plain language. Keep it in the repo. Future you
will thank present you.</p>
<p><strong>Use Simon Willison&rsquo;s &ldquo;showboat&rdquo; approach to document what was built.</strong> The
<a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer "><code>showboat</code></a> tool &ldquo;creates executable demo
documents that show and prove an agent&rsquo;s work.&rdquo; (kind of like a Jupyter
notebook, but just markdown). The LLM walks through its output with explanation
and context. It&rsquo;s a great way to produce living documentation that captures not
just what the code does, but why it was written that way. It&rsquo;s a great tool,
but not suitable for every use case.</p>
<p><strong>Read the LLM&rsquo;s thinking, especially when debugging.</strong> Many LLMs can expose
their reasoning process. When you&rsquo;re stuck on a bug or trying to understand a
decision, asking the LLM to think out loud before answering is one of the
fastest ways to build genuine understanding rather than just getting an answer.</p>
<p><strong>Write the tests yourself.</strong> Even if you let the LLM write the implementation,
writing the tests forces you to reason about the behavior you actually want.
It&rsquo;s one of the best ways to make sure the mental model gets built. Of course,
it takes more time and it&rsquo;s not always possible, like with JoyConf where I
didn&rsquo;t know enough about the Elixir environment to write effective tests. But
when you can, it&rsquo;s a great way to stay in the driver&rsquo;s seat.</p>
<p><strong>Slow down at decision points.</strong> LLMs are fast. That&rsquo;s the point. But speed
can accelerate debt. When you hit a fork in the road (an architectural choice, a
tradeoff, a &ldquo;there are a few ways to do this&rdquo; moment) pause and do the reasoning
yourself, even if you use the LLM to help you think it through.</p>
<h2 id="the-goal-isnt-to-use-llms-less">The goal isn&rsquo;t to use LLMs less</h2>
<p>LLMs are genuinely useful and I don&rsquo;t plan to stop using them. The goal is to
stay in the driver&rsquo;s seat mentally, using them for leverage rather than as a
substitute for thinking.</p>
<p>A healthy LLM workflow and a debt-generating one can look identical from the
outside. The difference shows up later, when you need to understand, maintain, or
extend what you built. If you finish each session understanding what you built
and why, you&rsquo;re using the tool well. If you don&rsquo;t, you&rsquo;re taking out a loan.</p>
<p>And like financial debt, cognitive debt is a lot easier to avoid than to pay off.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://arxiv.org/abs/2506.08872" target="_blank" rel="noopener noreffer ">MIT study on cognitive debt</a></li>
<li><a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer ">Simon Willison&rsquo;s showboat tool</a></li>
<li><a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">JoyConf repo</a></li>
<li><a href="https://tracyatteberry.com/posts/joyconf/" target="_blank" rel="noopener noreffer ">JoyConf story</a></li>
</ul>
]]></description></item><item><title>JoyConf</title><link>https://tracyatteberry.com/portfolio/joyconf/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/joyconf/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/joyconf/hero.png" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>JoyConf: a live emoji reaction app for presentations</title><link>https://tracyatteberry.com/posts/joyconf/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/joyconf/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/joyconf/hero.png" referrerpolicy="no-referrer">
            </div><h1 id="i-built-a-live-emoji-reaction-app-for-my-upcoming-talk">I built a live emoji reaction app for my upcoming talk</h1>
<p>I have a talk coming up in a few weeks. It got rescheduled once, which gave me
extra time to prepare. The presentation itself is finished, so I used that time
to build an application that I think will make the talk more fun and engaging
for the audience.</p>
<p>I wanted to add some live audience interaction. The usual options, Slido,
Mentimeter, Poll Everywhere, are fine, but they&rsquo;re designed around Q&amp;A and
polls. What I actually wanted was simpler and more visual: live emoji reactions
that float up as an overlay on my slides while I&rsquo;m presenting. None of the
existing tools seem to do that. So I built my own, and I called it JoyConf.</p>
<p>It also gave me a reason to finally learn some Elixir.</p>
<hr>
<h2 id="what-it-does">What it does</h2>
<p>The flow is simple:</p>
<ol>
<li>You create a talk in the admin panel and get a QR code</li>
<li>You put the QR code on your title slide</li>
<li>Attendees scan it and land on a page with pre-defined set emoji buttons: ❤️ 😂 🙋🏻 👏 🤯, etc.</li>
<li>They tap a button, and the emoji floats up on their screen in real time</li>
<li>A Chrome extension running on your laptop picks up the same broadcast and
overlays the emoji directly in the lower right corner of your Google Slides presentation</li>
</ol>
<p>That&rsquo;s it. No accounts for attendees, no app to install, no configuration to
fiddle with. Scan, tap, react.</p>
<hr>
<h2 id="why-elixir-and-phoenix">Why Elixir and Phoenix</h2>
<p>I write Ruby day to day, so the syntax was familiar enough. But Elixir runs on
the BEAM, the Erlang virtual machine, which was built for soft real-time
systems with lots of concurrent connections. Phoenix LiveView lets you build
interactive, server-rendered UIs over WebSockets without writing much
JavaScript. And Phoenix PubSub gives you a message bus that lets any process
broadcast to any other, regardless of what type of process it is.</p>
<p>All three of those things are important for a system where dozens of phones are
sending events to a server that needs to fan them out to a slide presentation
and all the phones (for their own consolidated live view) in under a second.</p>
<p>LiveView is pretty magical, by the way. You define your UI as a function of
state, and Phoenix handles keeping the browser in sync. You get
WebSocket-backed interactivity without writing a single-page app.</p>
<hr>
<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>
<p>There are three clients talking to one Phoenix server.</p>
<p><strong>Attendee phones</strong> connect via Phoenix LiveView, which manages the WebSocket
lifecycle automatically. When an attendee taps an emoji, it fires a <code>phx-click</code>
event to the server. The server checks a rate limiter (one reaction per session
every 3 seconds) and, if allowed, broadcasts the event to a PubSub topic keyed
by the talk slug: <code>&quot;reactions:my-talk&quot;</code>.</p>
<p><strong>The Chrome extension</strong> connects via a Phoenix Channel, a lower-level
WebSocket primitive. The extension can&rsquo;t use LiveView because it&rsquo;s not a web
page; it just needs to receive events. The <code>ReactionChannel</code> is subscribed to
the same PubSub topic, so when the attendee&rsquo;s broadcast lands, it gets
forwarded to the extension automatically.</p>
<p><strong>The admin browser</strong> is a standard LiveView protected by HTTP Basic Auth. You
create a talk, the server generates a slug from the title, and you get a QR
code pointing at the attendee URL.</p>
<p>The PubSub layer is what makes the architecture clean. <code>TalkLive</code> doesn&rsquo;t need
to know the Chrome extension exists. It just broadcasts on the topic, and
PubSub delivers it to everyone subscribed, whether that&rsquo;s a LiveView process, a
Channel process, or both.</p>
<p>Here&rsquo;s the entire broadcast path in code:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s the whole thing. Five lines. PubSub does the fan-out.</p>
<h3 id="rate-limiting-with-ets">Rate limiting with ETS</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table. ETS is an in-memory
key/value store built into the BEAM runtime. It&rsquo;s extremely fast and,
crucially, you can configure it for concurrent reads without going through the
GenServer process. This matters because with many attendees tapping at once,
you don&rsquo;t want all those requests queuing up behind a single process.</p>
<p>The table stores <code>{session_id, last_reaction_at}</code>. The <code>allow?/1</code> function
looks up the session, checks if enough time has passed, and updates the
timestamp atomically. No database round-trip, no lock contention.</p>
<p>There&rsquo;s also a client-side rate limit in JavaScript: buttons are disabled for 3
seconds with a visible countdown timer. That&rsquo;s just UX, the real enforcement is
on the server.</p>
<h3 id="the-chrome-extension">The Chrome extension</h3>
<p>The extension has two parts: a popup where you enter the talk slug once, and a
content script injected into Google Slides pages that handles the actual
connection and overlay.</p>
<p>The content script connects a Phoenix WebSocket client to the server, joins the
<code>reactions:${slug}</code> channel, and listens for <code>new_reaction</code> messages. When one
arrives, it spawns a floating <code>&lt;span&gt;</code> element with a CSS animation that drifts
up and fades out.</p>
<hr>
<h2 id="it-never-works-on-the-first-try">It <em>never</em> works on the first try</h2>
<p><strong>Double emojis on slides.</strong> Early on, every reaction appeared twice on the
speaker&rsquo;s screen. The bug was that the Chrome extension was subscribing to
PubSub directly as well as receiving the Channel push. Two subscriptions, two
deliveries. The fix was removing the redundant subscription and letting the
Channel handle delivery exclusively.</p>
<p><strong>Fullscreen mode swallows the overlay.</strong> When you go fullscreen in Google
Slides, the browser creates a new stacking context. Any <code>position: fixed</code>
element on <code>&lt;body&gt;</code> becomes invisible. The fix was listening for
<code>fullscreenchange</code> events and re-parenting the overlay <code>&lt;div&gt;</code> into
<code>document.fullscreenElement</code> when it fires:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-javascript">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;fullscreenchange&#34;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">overlay</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s2">&#34;joyconf-overlay&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>Not obvious, but straightforward once you know it.</p>
<p><strong>Button clicks were being swallowed.</strong> The initial implementation disabled the
emoji buttons immediately on click. That blocked the <code>phx-click</code> handler from
firing, so the server never received the event. The fix was deferring the
button disable to a <code>setTimeout(..., 0)</code>, which lets the click event propagate
before the buttons get disabled.</p>
<p><strong>The Chrome extension&rsquo;s origin.</strong> Chrome extensions run from a
<code>chrome-extension://</code> origin, which Phoenix&rsquo;s <code>check_origin</code> protection rejects
by default. One line in <code>endpoint.ex</code> fixes it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">socket</span> <span class="s2">&#34;/socket&#34;</span><span class="p">,</span> <span class="nc">JoyconfWeb.UserSocket</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">websocket</span><span class="p">:</span> <span class="p">[</span><span class="ss">check_origin</span><span class="p">:</span> <span class="no">false</span><span class="p">]</span></span></span></code></pre></div></div>
<hr>
<h2 id="the-tech-stack-briefly">The tech stack, briefly</h2>
<table>
	<thead>
			<tr>
					<th>Concern</th>
					<th>Choice</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Language / framework</td>
					<td>Elixir / Phoenix LiveView</td>
			</tr>
			<tr>
					<td>Real-time</td>
					<td>Phoenix PubSub + Channels</td>
			</tr>
			<tr>
					<td>Database</td>
					<td>PostgreSQL (Fly.io managed)</td>
			</tr>
			<tr>
					<td>Rate limiting</td>
					<td>ETS-backed GenServer</td>
			</tr>
			<tr>
					<td>QR codes</td>
					<td><code>eqrcode</code> hex package</td>
			</tr>
			<tr>
					<td>Deployment</td>
					<td>Fly.io</td>
			</tr>
			<tr>
					<td>Extension</td>
					<td>Chrome Manifest V3</td>
			</tr>
	</tbody>
</table>
<p>The database has one table: <code>talks</code>. Reactions are ephemeral and never
persisted. If the server restarts, in-flight reactions are lost, which is fine.</p>
<hr>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>JoyConf is an MVP. It does one thing and it&rsquo;s ready for me to use at my talk.
It&rsquo;s not productized, it&rsquo;s not super-polished, and it&rsquo;s likely not easy for
the average non-technical user to deploy.</p>
<p>Things that might be nice to add in the future:</p>
<ul>
<li>Ease of deployment for less technical users.</li>
<li>Reaction analytics tied to slides so you can see which parts of your talk landed</li>
<li>Support for other presentation tools beyond Google Slides</li>
</ul>
<p>For now, I&rsquo;m just going to go use it.</p>
<hr>
<p>The source code is on <a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">GitHub</a>. If you
want to run your own instance, the README should have everything you need.</p>
]]></description></item></channel></rss>