<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Robert Kovacs | Software Engineer | Ruby</title>
    <link>https://robikovacs.com/</link>
    <description>Ruby developer specialized in building elegant solutions for SaaS, e-commerce, and mobile applications.</description>
    <language>en-us</language>
    <lastBuildDate>Mon, 13 Apr 2026 17:44:05 +0000</lastBuildDate>
    <atom:link href="https://robikovacs.com/rss.xml" rel="self" type="application/rss+xml" />
    
      
    <item>
      <title>Hands-Free Claude Code: How I Ship Features With Wispr Flow and a Game Controller</title>
      <link>https://robikovacs.com/blog/coding-hands-free-claude-code-wispr-flow/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/coding-hands-free-claude-code-wispr-flow/</guid>
      <pubDate>Mon, 13 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>My hands-free Claude Code Desktop workflow — Wispr Flow for voice input, an 8BitDo Micro controller for approvals. No keyboard, no mouse, just shipping features.</description>
      <category>[&quot;ai</category><category>&quot;</category><category>&quot;productivity</category><category>&quot;</category><category>&quot;webdev</category><category>&quot;</category><category>&quot;tutorial&quot;]</category>
      <content:encoded><![CDATA[<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--banner.png" alt="Coding hands-free with Claude Code and Wispr Flow" /></p>

<p>I’ve been writing code without touching the keyboard. Not as a gimmick — as my actual daily workflow for the last few weeks.</p>

<p>The setup has two parts:</p>

<ul>
  <li><a href="https://ref.wisprflow.ai/robert-kovacs">Wispr Flow</a> for voice input</li>
  <li>An <a href="https://www.8bitdo.com/micro/">8BitDo Micro</a> bluetooth controller for everything else</li>
</ul>

<p>That’s it.</p>

<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--controller-in-hand.webp" alt="8BitDo Micro controller in my hand" />
<em>The <a href="https://www.8bitdo.com/micro/">8BitDo Micro</a>. About the size of a keychain.</em></p>

<h2 id="why-the-controller">Why the controller?</h2>

<p>Voice input alone doesn’t work. Claude Code constantly hands you things you have to press a key for — and it’s not just “approve” prompts. Sometimes it’s a list of options to pick from, sometimes you want to cancel what it’s doing mid-stream, sometimes you need to scroll through a diff or jump between files. Saying “yes” into <a href="https://ref.wisprflow.ai/robert-kovacs">Wispr</a> just types the word “yes” into the chat box — not what you want.</p>

<p>You need real keys: Enter to accept, Esc to cancel, arrows to move through options, Shift+Tab to step backward. A keyboard does this fine — but then you’re back at the keyboard.</p>

<p>So I bought a tiny gamepad, put it in keyboard mode, and mapped its buttons to the exact keys Claude Code Desktop listens for. Accept, reject, navigate, escape. Done.</p>

<p>Quick note: the 8BitDo desktop app didn’t work for me (couldn’t see the controller), but the <a href="https://apps.apple.com/us/app/8bitdo-ultimate-software/id1532713768">iOS app</a> did — I mapped everything from my phone.</p>

<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--controller-mapping.webp" alt="8BitDo Ultimate Software button mapping" />
<em>Button mapping in 8BitDo’s Ultimate Software. Enter, Esc, arrows, Shift+Tab — the keys Claude Code actually cares about. The up/down arrows look swapped because I hold the controller rotated (see the GIF below), so the D-pad ends up pointing the “right” way from my grip.</em></p>

<h2 id="how-it-actually-works">How it actually works</h2>

<p>Hold <code>Ctrl+Cmd+&#96;</code>, say what I want, let go. Wispr transcribes it straight into the Claude Code chat box. (Wispr defaults to <code class="language-plaintext highlighter-rouge">fn</code>, but <code class="language-plaintext highlighter-rouge">fn</code> can’t be mapped to a controller button, so I use the key combo and bind it to the controller instead.)</p>

<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--wispr-shortcuts.webp" alt="Wispr Flow shortcuts settings" />
<em>Two push-to-talk shortcuts — I use the <code>Ctrl+Cmd+&#96;</code> combo because it’s mappable to the controller.</em></p>

<p>Claude Code goes off and does the thing. Reads files, edits them, runs the dev server, screenshots the preview. When it asks for approval, I hit the accept button on the controller. When I want to redirect it, I hold the combo and talk again.</p>

<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--claude-code-desktop.webp" alt="Claude Code Desktop running a preview" />
<em>Claude Code Desktop. Chat on the left, live preview on the right. Controller does the approvals.</em></p>

<p>Here’s a real clip — me describing a feature, Claude Code doing it, me approving with the controller, done:</p>

<p><img src="https://robikovacs.com/assets/images/hands-free-claude-code--demo.gif" alt="Hands-free Claude Code workflow demo" /></p>

<p>Zero keystrokes. The feature is in the preview before I’ve finished my coffee.</p>

<h2 id="why-i-actually-do-this">Why I actually do this</h2>

<p>Thought it would be a novelty. It’s not. A few real reasons it stuck:</p>

<p><strong>Productivity.</strong> Talking forces me to explain what I want — intent, constraints, edge cases — instead of jumping into code. Claude gets a better brief, the first attempt is closer to right. My “thinking out loud” became the spec.</p>

<p><strong>Less time hunched over the keyboard.</strong> Eight hours a day of typing catches up with you. Offloading most of the keypresses to voice + a thumb on a controller means my wrists and shoulders get an actual break, even on long days.</p>

<p><strong>It feels like Iron Man.</strong> Honestly. Talking to your environment, pressing one button to act, watching things build themselves in front of you — it’s a bit absurd, a bit magical, and it makes me want to keep working instead of wanting a break.</p>

<p>One nice bonus: <a href="https://ref.wisprflow.ai/robert-kovacs">Wispr Flow</a> keeps a history of everything you dictate. If Claude eats your prompt, or you want to reuse a description, it’s all there in the transcript panel.</p>

<h2 id="the-setup-in-30-seconds">The setup in 30 seconds</h2>

<ol>
  <li>Install <a href="https://ref.wisprflow.ai/robert-kovacs">Wispr Flow</a> (free tier works fine). Set push-to-talk to a key combo you can map to a button (I use <code>Ctrl+Cmd+&#96;</code>).</li>
  <li>Get an <a href="https://www.8bitdo.com/micro/">8BitDo Micro</a> (or any programmable bluetooth controller). Map the buttons you need — Enter for accept, Esc for reject, arrows for navigation, plus your Wispr shortcut.</li>
  <li>Open Claude Code Desktop. Focus the chat box.</li>
  <li>Talk. Press the button when it asks. Repeat.</li>
</ol>

<p>The interesting part isn’t the tools. It’s noticing how much of coding is describing intent — and how much better that works out loud, with a controller in your hand, than hunched over a keyboard.</p>

<h2 id="setting-up-your-dev-environment-with-claude-code-previews">Setting up your dev environment with Claude Code previews</h2>

<p>At <a href="https://firstpromoter.com/?fpr=robert">FirstPromoter</a>, our platform spans four distinct frontends — an Admin Dashboard, an Affiliate Portal, a Support Dashboard, and our public Docs site — all living as git submodules inside a single monorepo alongside our Rails API and dbt analytics project. Wiring this up to Claude Code’s new desktop preview feature took a single file, <code class="language-plaintext highlighter-rouge">.claude/launch.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.0.1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"configurations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Admin"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeExecutable"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docker-compose"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeArgs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"up"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">5173</span><span class="p">,</span><span class="w">
      </span><span class="nl">"autoPort"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Affiliate"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeExecutable"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docker-compose"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeArgs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"up"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">3001</span><span class="p">,</span><span class="w">
      </span><span class="nl">"autoPort"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Support"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeExecutable"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docker-compose"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeArgs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"up"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">5175</span><span class="p">,</span><span class="w">
      </span><span class="nl">"autoPort"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Docs"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeExecutable"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docker-compose"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"runtimeArgs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"up"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">3003</span><span class="p">,</span><span class="w">
      </span><span class="nl">"autoPort"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Each frontend is declared as a preview configuration that shells out to <code class="language-plaintext highlighter-rouge">docker-compose up</code>, mapped to the port its service is exposed on. Because every preview shares the same Docker Compose stack, launching any one of them brings up the entire backend — Rails API, Postgres, Redis, Sidekiq, Elasticsearch — so Claude can click through the Admin UI, hit a real API, and watch the console for errors without me juggling a dozen terminals.</p>

<p>Once it’s wired up, the workflow gets even more hands-free: I just say <em>“open the Admin preview”</em> (or Affiliate, Support, Docs) and Claude starts the right one and opens it in the side panel. No terminal, no remembering ports.</p>

<p>The result: Claude can preview a feature, review the diff inline, and push the PR through CI to merge — all without leaving the desktop app, and without writing a single bespoke dev script on top of the Docker setup <a href="https://firstpromoter.com/?fpr=robert">FirstPromoter</a> already had.</p>
]]></content:encoded>
    </item>
    
      
    <item>
      <title>Ruby on Rails Performance: 7 Lessons from Scaling FirstPromoter</title>
      <link>https://robikovacs.com/blog/ruby-on-rails-performance-lessons-scaling-saas/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/ruby-on-rails-performance-lessons-scaling-saas/</guid>
      <pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>Real-world Rails performance lessons from scaling an affiliate platform handling 35M+ requests per week and 10M payment webhooks. Counter caching, N+1 fixes, Redis, Sidekiq, BigQuery, and more.</description>
      <category>[&quot;ruby</category><category>&quot;</category><category>&quot;rails</category><category>&quot;</category><category>&quot;performance</category><category>&quot;</category><category>&quot;postgres&quot;]</category>
      <content:encoded><![CDATA[<p>When your Rails app grows from a handful of users to millions of referrals across thousands of programs, performance becomes the whole job. I work on <a href="https://firstpromoter.com/?fpr=robert">FirstPromoter</a>, an affiliate tracking platform powering 7,000+ affiliate programs. Over the years we’ve had to figure out how to keep things fast as the traffic kept growing.</p>

<p><img src="https://robikovacs.com/assets/images/firstpromoter-performance--company-dashboard.webp" alt="FirstPromoter company dashboard" />
<em>The FirstPromoter dashboard — the page that used to time out</em></p>

<p>Here’s what a typical week looks like in production:</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Requests / week</td>
      <td><strong>35.5M</strong></td>
    </tr>
    <tr>
      <td>Payment webhooks / week</td>
      <td><strong>10.3M</strong></td>
    </tr>
    <tr>
      <td>Avg webhook response</td>
      <td><strong>23ms</strong></td>
    </tr>
    <tr>
      <td>Peak requests / hour</td>
      <td><strong>318K</strong></td>
    </tr>
  </tbody>
</table>

<p>That’s more than double the throughput from last year (15.7M → 35.5M) — while simultaneously making things faster. We process payment webhooks from Stripe, Chargebee, Braintree, and Paddle — about 1.47 million per day. Each one needs to be fast and reliable because it directly affects revenue attribution for our customers.</p>

<p>Here are seven lessons we learned the hard way.</p>

<h2 id="1-counter-caching-saves-more-than-you-think">1. Counter Caching Saves More Than You Think</h2>

<p>Our dashboard showed counts everywhere — active promoters per campaign, pending referrals, total commissions. Each one a <code class="language-plaintext highlighter-rouge">COUNT(*)</code> query hitting PostgreSQL in real time.</p>

<p><img src="https://robikovacs.com/assets/images/firstpromoter-performance--promoter-dashboard.webp" alt="FirstPromoter promoter dashboard" />
<em>Promoter detail view — counts everywhere, each one a potential COUNT query</em></p>

<p>Rails’ built-in <code class="language-plaintext highlighter-rouge">counter_cache</code> got us started, but we graduated to <a href="https://github.com/magnusvk/counter_culture">counter_culture</a> for conditional counts and multi-level caching.</p>

<p>The real lesson wasn’t “add counter caches” though — it was <strong>scope your cache refreshes</strong>. We were recalculating counters for archived and inactive companies on every cron sweep. Scoping the refresh to active companies only cut our background job time dramatically:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before: refreshing everything, including dead data</span>
<span class="no">Campaign</span><span class="p">.</span><span class="nf">counter_culture_fix_counts</span>

<span class="c1"># After: scope to what actually matters</span>
<span class="no">Company</span><span class="p">.</span><span class="nf">active</span><span class="p">.</span><span class="nf">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">company</span><span class="o">|</span>
  <span class="no">Campaign</span><span class="p">.</span><span class="nf">counter_culture_fix_counts</span><span class="p">(</span>
    <span class="ss">where: </span><span class="p">{</span> <span class="ss">company_id: </span><span class="n">company</span><span class="p">.</span><span class="nf">id</span> <span class="p">}</span>
  <span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>We also moved reconciliation to an async worker that reads from a replica database — so the hourly counter refresh doesn’t touch the primary at all:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">CounterCulture</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">use_read_replica</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">env</span><span class="p">.</span><span class="nf">production?</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Our dashboard stats endpoint went from <strong>10s+</strong> to <strong>2.5s</strong>, and the promoter listings that used to choke on COUNT queries now load in under <strong>500ms</strong>.</p>

<h2 id="2-n1-queries-preloading-too-much-is-as-bad-as-too-little">2. N+1 Queries: Preloading Too Much Is as Bad as Too Little</h2>

<p>Everyone knows to fix N+1 queries. The less obvious lesson: preloading too aggressively can be just as bad. We’ve seen developers throw every association into <code class="language-plaintext highlighter-rouge">includes</code> and wonder why memory usage spikes.</p>

<p>We run <a href="https://github.com/flyerhzm/bullet">Bullet</a> and <a href="https://github.com/charkost/prosopite">Prosopite</a> in development to catch issues, but the real discipline is profiling first, then adding preloads only for what the serializer actually touches. Nothing more.</p>

<h2 id="3-redis-for-the-hot-path">3. Redis for the Hot Path</h2>

<p>When you’re processing 1.47M webhooks per day, every millisecond in your hot path counts. We use Redis for four things:</p>

<p><strong>Webhook queue.</strong> We don’t process webhooks inline. Validate the payload, enqueue a Sidekiq worker, return 200. The actual business logic happens in the background. That’s how we handle 10M+ webhooks per week — the response time is just validation and enqueue.</p>

<p><strong>Atomic counters.</strong> Email send counts, tracking events — anything high-frequency that would cause database contention. We increment in Redis and flush to PostgreSQL periodically.</p>

<p><strong>Revenue caching.</strong> Dashboard revenue calculations that join multiple tables get cached in Redis with Sidekiq workers refreshing on a schedule. The dashboard loads instantly while data stays reasonably fresh.</p>

<p><strong>Rails cache store.</strong> Query and fragment caching backed by Redis with connection pooling — essential to prevent connection exhaustion under load.</p>

<h2 id="4-sidekiq-tune-it-like-an-engine">4. Sidekiq: Tune It Like an Engine</h2>

<p>With over 100K scheduled jobs in queue at any given time, Sidekiq tuning isn’t optional.</p>

<p><strong>Dedicated processes for critical paths.</strong> We run separate Sidekiq processes for tracking (most latency-sensitive), mailers, and general work. A bulk CSV export shouldn’t be able to starve real-time webhook processing.</p>

<p><strong>Queue limits.</strong> <a href="https://github.com/deanpcmad/sidekiq-limit_fetch">sidekiq-limit_fetch</a> sets explicit concurrency limits per queue. Without it, a flood of low-priority jobs monopolizes every worker.</p>

<p><strong>Consolidate related workers.</strong> We had separate workers for archiving promoters, referrals, and commissions. Consolidating them into a single job with SQL <code class="language-plaintext highlighter-rouge">FILTER</code> aggregation cut database round-trips and was far easier to reason about.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># sidekiq.yml — separate concerns</span>
<span class="na">:queues</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="pi">[</span><span class="nv">tracking</span><span class="pi">,</span> <span class="nv">10</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="pi">[</span><span class="nv">critical</span><span class="pi">,</span> <span class="nv">8</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="pi">[</span><span class="nv">mailers</span><span class="pi">,</span> <span class="nv">5</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="pi">[</span><span class="nv">default</span><span class="pi">,</span> <span class="nv">3</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="pi">[</span><span class="nv">low</span><span class="pi">,</span> <span class="nv">1</span><span class="pi">]</span>
</code></pre></div></div>

<h2 id="5-know-when-postgresql-isnt-enough">5. Know When PostgreSQL Isn’t Enough</h2>

<p>Our dashboard analytics — trending charts, revenue breakdowns, time-series comparisons — were bringing PostgreSQL to its knees. Queries scanning millions of records timing out at 30 seconds. Not slow — literally timing out.</p>

<p>We migrated the analytics layer to <a href="https://cloud.google.com/bigquery">Google BigQuery</a>. Same queries that timed out in PostgreSQL now run in under 2 seconds. But <strong>not everything belongs in BigQuery</strong> — we initially moved too aggressively and actually reverted some queries back when the added complexity wasn’t justified. Our rule of thumb: if a query scans hundreds of thousands of rows or involves complex time-series aggregations, BigQuery. Everything else stays in PostgreSQL.</p>

<p>Here’s where things stand now — 95th percentile response times over the last 30 days:</p>

<table>
  <thead>
    <tr>
      <th>Namespace</th>
      <th>P95</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Webhooks</td>
      <td><strong>85ms</strong></td>
    </tr>
    <tr>
      <td>Background</td>
      <td><strong>1,969ms</strong></td>
    </tr>
    <tr>
      <td>API</td>
      <td><strong>3,426ms</strong></td>
    </tr>
  </tbody>
</table>

<p>95% of API requests complete under 3.4s. These are the same analytics queries that used to time out at 30s.</p>

<h2 id="6-database-views-for-complex-reports">6. Database Views for Complex Reports</h2>

<p>We started with the <a href="https://github.com/scenic-views/scenic">Scenic</a> gem to create PostgreSQL views for reporting queries that would otherwise require joining five or six tables every time. That worked until it didn’t — the views got too complex and too slow as the data grew. We ended up moving the heavy ones to BigQuery, where the same join logic runs over millions of rows without breaking a sweat. The simpler views still live in PostgreSQL where they belong.</p>

<h2 id="7-batch-size-is-a-tuning-parameter">7. Batch Size Is a Tuning Parameter</h2>

<p>When exporting large datasets, batch size dramatically affects both memory and speed. We iterated from the Rails default of 1,000 up to 50,000 (memory issues) and back down. The answer: <strong>different sizes for different models</strong>. A lightweight model handles 50K batches fine. A model with multiple eager-loaded associations might cap out at 5K before memory gets uncomfortable.</p>

<h2 id="where-the-traffic-comes-from">Where the Traffic Comes From</h2>

<p>Payment webhooks dominate our traffic. Here’s the breakdown by provider:</p>

<table>
  <thead>
    <tr>
      <th>Provider</th>
      <th>Weekly Volume</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Stripe</td>
      <td><strong>5.8M</strong></td>
    </tr>
    <tr>
      <td>Chargebee</td>
      <td><strong>3.7M</strong></td>
    </tr>
    <tr>
      <td>Braintree</td>
      <td><strong>287K</strong></td>
    </tr>
    <tr>
      <td>Paddle</td>
      <td><strong>113K</strong></td>
    </tr>
    <tr>
      <td>Recurly</td>
      <td><strong>36K</strong></td>
    </tr>
  </tbody>
</table>

<p>Each integration has its own payload format, retry behavior, and edge cases. We keep an eye on all of it through <a href="https://www.appsignal.com/">AppSignal</a> for performance monitoring, <a href="https://betterstack.com/">Better Stack</a> for logs, <a href="https://github.com/flyerhzm/bullet">Bullet</a>/<a href="https://github.com/charkost/prosopite">Prosopite</a> for N+1 detection, and <a href="https://github.com/ankane/strong_migrations">strong_migrations</a> to keep deploys safe.</p>

<p>These lessons came from years of working on <a href="https://firstpromoter.com/?fpr=robert">FirstPromoter</a>. If you’re building a SaaS and thinking about launching an affiliate or referral program, <a href="https://firstpromoter.com/?fpr=robert">give it a try</a>.</p>

<p>Find me on <a href="https://www.linkedin.com/in/rikovacs/">LinkedIn</a> or <a href="https://github.com/robikovacs">GitHub</a>.</p>
]]></content:encoded>
    </item>
    
      
    <item>
      <title>What Happens When Your API Has to Open a Door</title>
      <link>https://robikovacs.com/blog/building-software-for-physical-spaces-at-room/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/building-software-for-physical-spaces-at-room/</guid>
      <pubDate>Mon, 15 Mar 2021 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>Building software at ROOM that controls physical locks, tracks IoT sensors, and manages office spaces — the weird problems you don&apos;t get in pure SaaS.</description>
      <category>[&quot;webdev</category><category>&quot;</category><category>&quot;rails</category><category>&quot;</category><category>&quot;typescript</category><category>&quot;</category><category>&quot;reactnative&quot;]</category>
      <content:encoded><![CDATA[<p>I’ve been at <a href="https://room.com">ROOM</a> for a while now, working across their platform. ROOM makes phone booths and meeting rooms for offices, and the software side is about making those physical spaces bookable, trackable, and manageable. We run a Rails API for the B2B portal, a Node/TypeScript API for the consumer booking app, and a React Native mobile app.</p>

<p>Most of my bugs can’t be reproduced on localhost.</p>

<h2 id="unlocking-doors-over-http">Unlocking doors over HTTP</h2>

<p>The booking app lets people reserve a phone booth, walk up to it, and unlock it from their phone. We integrate with Salto and Tapkey hardware locks. When a user taps “unlock,” our API sends a command to a physical lock on a physical door in a physical building somewhere.</p>

<p>This changes how you think about error handling. HTTP timeouts mean something different when the consequence is someone standing in a hallway. We built fallback flows with OTP codes, lock status polling, and graceful degradation for when the hardware goes offline.</p>

<p>The interesting edge case: what happens when someone is inside and their card declines? You can’t lock someone inside a room because their card declined. The physical world doesn’t care about your payment flow.</p>

<p><img src="https://robikovacs.com/assets/images/room--booking-app.webp" alt="ROOM booking app" />
<em>The booking flow — find a room, reserve it, walk up and unlock</em></p>

<h2 id="the-data-pipeline-nobody-warned-me-about">The data pipeline nobody warned me about</h2>

<p>Every ROOM unit has a Particle IoT device inside it. Tracks occupancy — when someone enters, when they leave, signal strength, device health. That data flows through Google Pub/Sub into BigQuery, and our Rails API pulls it back out for analytics dashboards.</p>

<p>Sounds simple: device sends event, Pub/Sub queues it, BigQuery stores it, Rails reads it back. In practice: devices drop offline, send duplicate events, have clock drift. A unit in Tokyo and a unit in New York need their sessions in the right timezone. Devices sometimes report “occupied” when nobody’s there (the sensor picked up movement outside the booth). You end up writing reconciliation logic you never planned for.</p>

<p>The portal shows office managers how their spaces are actually used. Turns out most companies have no idea. The data is usually surprising — the meeting room that seats 4 gets used by 1 person for calls, and the phone booths are at 90% utilization.</p>

<p><img src="https://robikovacs.com/assets/images/room--sense-dashboard.webp" alt="Sense analytics dashboard" />
<em>The Sense dashboard — utilization data from IoT sensors across offices</em></p>

<h2 id="two-stacks-same-door">Two stacks, same door</h2>

<p>The B2B portal is Rails — organizations, offices, units, NetSuite for invoicing, Salesforce sync. The consumer app is Node with Prisma and TypeScript — sessions, Stripe payments, magic link auth.</p>

<p>Different products, different users, different tech stacks, same physical units.</p>

<p>A unit registered in the portal needs to show up in the booking app. Pricing changes in one system need to reflect in the other. We made it work but it was a recurring source of bugs. Back when I was <a href="https://robikovacs.com/blog/why-i-build-with-ruby-on-rails/">just getting started with Rails</a> I thought picking the right framework was the hard part. Turns out keeping two of them in sync is harder.</p>

<h2 id="what-i-took-away-from-this">What I took away from this</h2>

<p>Software for physical spaces is humbling. You can’t roll back a deployment when someone is locked inside a phone booth. Latency matters differently when it’s the gap between tapping a button and hearing a lock click. Your test suite can’t open a real door.</p>

<p>Before ROOM everything I built lived in the browser. IoT work broke that mental model. Redis caching, background job patterns, careful error handling — I learned all of it here the hard way, because a sensor was lying about occupancy at 3am.</p>
]]></content:encoded>
    </item>
    
      
    <item>
      <title>What 2018 Taught Me About Building Software</title>
      <link>https://robikovacs.com/blog/a-year-of-building-2018-in-review/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/a-year-of-building-2018-in-review/</guid>
      <pubDate>Tue, 25 Dec 2018 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>Lessons from a year of shipping products, growing as a developer, and figuring out that writing code is only part of the job.</description>
      <category>[&quot;career</category><category>&quot;</category><category>&quot;programming</category><category>&quot;</category><category>&quot;webdev</category><category>&quot;</category><category>&quot;learning&quot;]</category>
      <content:encoded><![CDATA[<p>End of the year, time to look back. The team doubled, the projects got bigger, and I spent way more time not writing code than I expected.</p>

<p>Biggest surprise: how much of this job is communication. Understanding what a client actually needs vs what they’re asking for. Figuring out why a project is stuck when the code is fine. Got better at this mostly by getting it wrong a few times.</p>

<p>I want to get serious about performance in 2019. The projects are outgrowing my understanding of caching, background jobs, and database optimization. I’ve been leaning on Rails defaults and they’ve been fine, but “fine” won’t cut it much longer.</p>

<p><em>Related: our team’s <a href="https://medium.com/wolfpack-digital/2018-in-review-5af1fe84874a">2018 year in review</a> on the Wolfpack Digital blog.</em></p>
]]></content:encoded>
    </item>
    
      
    <item>
      <title>We Built a Bike Security App in 24 Hours</title>
      <link>https://robikovacs.com/blog/lockhere-techsylvania-hackathon/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/lockhere-techsylvania-hackathon/</guid>
      <pubDate>Fri, 22 Jun 2018 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>How Emil and I built LockHere at the Techsylvania hackathon — a mobile app that alerts you when someone touches your bike.</description>
      <category>[&quot;showdev</category><category>&quot;</category><category>&quot;ruby</category><category>&quot;</category><category>&quot;mobile</category><category>&quot;</category><category>&quot;webdev&quot;]</category>
      <content:encoded><![CDATA[<p>Emil and I entered the Techsylvania hackathon last week. 24 hours, connected devices theme. We built LockHere — detects motion around your bike, sends you a real-time alert with its location. Simple idea. Bikes get stolen in Cluj all the time.</p>

<p><img src="https://robikovacs.com/assets/images/lockhere-hackathon--bike-at-hackathon.webp" alt="The bike at the hackathon" /></p>

<p>Rails backend (obviously), Here maps for location, and whatever else they gave us — Telegram, IBM Cloud, Snips. The code was terrible but it worked. Won a special prize from Here, got a trip to Krakow for another hackathon.</p>

<p><img src="https://robikovacs.com/assets/images/lockhere-hackathon--techsylvania-presentation.webp" alt="Presenting at Techsylvania" /></p>

<p>Honestly the best part was just building something without process. No tickets, no standups, no PR reviews — just two people trying to ship before the timer runs out.</p>

<p><em>The Wolfpack team also <a href="https://medium.com/wolfpack-digital/our-story-about-techsylvania-and-lockhere-the-mobile-app-that-helps-you-safeguard-your-bike-da302055a83c">wrote about this</a> on their blog.</em></p>
]]></content:encoded>
    </item>
    
      
    <item>
      <title>Why I Keep Choosing Ruby on Rails</title>
      <link>https://robikovacs.com/blog/why-i-build-with-ruby-on-rails/</link>
      <guid isPermaLink="true">https://robikovacs.com/blog/why-i-build-with-ruby-on-rails/</guid>
      <pubDate>Tue, 14 Jun 2016 00:00:00 +0000</pubDate>
      <dc:creator>Robert Kovacs</dc:creator>
      <description>I just started my first job as a Rails developer. Here&apos;s why I picked it and what I think about the framework so far.</description>
      <category>[&quot;ruby</category><category>&quot;</category><category>&quot;rails</category><category>&quot;</category><category>&quot;beginners</category><category>&quot;</category><category>&quot;webdev&quot;]</category>
      <content:encoded><![CDATA[<p>Just started at <a href="https://wolfpack-digital.com">Wolfpack Digital</a> as a Ruby developer. I’ve been doing Java and Angular for the past two years, so this is a big switch. Figured I’d write down why Rails while it’s still fresh.</p>

<p><img src="https://robikovacs.com/assets/images/why-rails--dev-stickers.webp" alt="Dev stickers" /></p>

<p>Quick clarification because this trips people up: Ruby is the language, Rails is the framework on top of it. Handles routing, database stuff, the whole request lifecycle — so you skip the boilerplate and get to the actual product.</p>

<p>Two things hooked me. Convention over configuration — Rails decides where files go, how URLs work, how models connect to the database. You can override it all, but the defaults are good. And the gem ecosystem. Need auth? Devise. File uploads? CarrierWave. Admin panel? Rails Admin. Almost never starting from zero.</p>

<p>“But does it scale?” — yes. Shopify runs on Rails. GitHub ran on it for years. Basecamp still does. When things are slow it’s usually a query problem, not a Rails problem.</p>

<p>Not for everything — mobile apps, heavy computation, static sites, use something else. But for web apps and APIs, which is most of what we build here, nothing gets you moving this fast. After two years of Java, Ruby feels like a different world. Glad I made the switch.</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
