<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seb Hoek</title>
    <description>The latest articles on DEV Community by Seb Hoek (@sebhoek).</description>
    <link>https://dev.to/sebhoek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3697659%2F58f7bc14-4955-4ef1-956f-fb5c1f3cd23f.png</url>
      <title>DEV Community: Seb Hoek</title>
      <link>https://dev.to/sebhoek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sebhoek"/>
    <language>en</language>
    <item>
      <title>AI created slow and expensive code. How I analyzed and fixed it.</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Sun, 19 Apr 2026 08:30:04 +0000</pubDate>
      <link>https://dev.to/sebhoek/ai-created-slow-and-expensive-code-how-i-analyzed-and-fixed-it-2nla</link>
      <guid>https://dev.to/sebhoek/ai-created-slow-and-expensive-code-how-i-analyzed-and-fixed-it-2nla</guid>
      <description>&lt;p&gt;My AI-built browser game portal was growing. That was good news - until Firebase bills started rising and performance got worse.&lt;/p&gt;

&lt;p&gt;Now was the moment where I as a software engineer had to step in.&lt;/p&gt;

&lt;p&gt;With an increasing user base playing more and more games every day, I slipped out of the free usage tier for Firebase Storage which I use for persistency. &lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: The costs increased to about 10 USD per months with a clear upward trend&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Also, I noticed that the perceived performance of my HTTP services degraded over time.&lt;/p&gt;

&lt;p&gt;This is less than optimal. What happened? It probably had to do with how AI has set up the HTTP requests and database queries. &lt;/p&gt;

&lt;p&gt;How can I investigate the causes and what can I do to fix it? Let's dive into it!&lt;/p&gt;

&lt;h2&gt;
  
  
  Content
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Finding the real bottlenecks&lt;/li&gt;
&lt;li&gt;Problem #1: A nightly job burning reads&lt;/li&gt;
&lt;li&gt;Problem #2: One endpoint doing too much&lt;/li&gt;
&lt;li&gt;Problem #3: Random seeds were surprisingly expensive&lt;/li&gt;
&lt;li&gt;Back to free tier&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Finding the real bottlenecks
&lt;/h2&gt;

&lt;p&gt;For me, observability is one of the most useful tools for finding and understanding problems.&lt;/p&gt;

&lt;p&gt;Rather than guessing, I used three sources of data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Cloud Billing Reports to see where costs came from&lt;/li&gt;
&lt;li&gt;Firestore Query Insights to identify expensive collections&lt;/li&gt;
&lt;li&gt;API latency metrics in Google Cloud Observability to spot slow endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the metrics revealed
&lt;/h3&gt;

&lt;p&gt;The billing report identified Firestore as the only contributor to the costs. And within Firestore, it was clear that some collections had too many reads for the daily active users of my gaming portal.&lt;/p&gt;

&lt;p&gt;With up to 1 million reads per day, my small system exceeded the free tier threshold of 50k reads per day by more than factor 10.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: Firestore usage showed too many reads and writes before the optimization&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Firestore Query Insights indicated that some collections like the profile, game completion and highscores were the main source of the reads.&lt;/p&gt;

&lt;p&gt;After having set up the HTTP API metrics in Google Cloud Observability, I could see that the profile resource was queried too many times and had a high latency, and that the same applied to the random seed generator resource.&lt;/p&gt;

&lt;p&gt;With this information, I could challenge my coding assistant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which parts of the code read those collections too often?&lt;/li&gt;
&lt;li&gt;Why is the profile resource slow and called so frequently?&lt;/li&gt;
&lt;li&gt;Why is the random seed generator so slow?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three areas stood out immediately: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a nightly cleanup job, &lt;/li&gt;
&lt;li&gt;the profile endpoint, &lt;/li&gt;
&lt;li&gt;and the random seed generator. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they were driving most of the cost and latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #1: A nightly job burning reads
&lt;/h2&gt;

&lt;p&gt;The first surprise came from a background job that users never even saw.&lt;/p&gt;

&lt;p&gt;The nightly cleanup function, written by the coding assistant, had an N+1 read pattern that scaled poorly with the number of profiles. At small scale I didn't notice it, but with real usage it became a major cost driver.&lt;/p&gt;

&lt;h3&gt;
  
  
  What was going wrong
&lt;/h3&gt;

&lt;p&gt;The job iterated over every profile document and then ran a subquery per profile to find old game starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// loads ALL profiles&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profilesSnapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROFILE_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; 

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileDoc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;profilesSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;profileDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GAMESTATS_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// separate query per profile&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the job always performed one collection scan plus N additional subqueries, where N is the number of profiles - even if most profiles had nothing to clean up.&lt;/p&gt;

&lt;p&gt;In practice, with ~500 profiles but only ~30 containing stale data, the job still executed ~501 reads instead of ~30 relevant reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I fixed it
&lt;/h3&gt;

&lt;p&gt;I replaced the per-profile loop with a collection group query that directly targets only the documents that need cleanup::&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectionGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GAMESTARTS_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shifts the cost from being proportional to the number of profiles to being proportional to the number of matching documents.&lt;/p&gt;

&lt;p&gt;In the same example, that reduced the work from ~501 reads down to ~30.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;This single change removed a large portion of the Firestore cost baseline. It also made the cleanup job scale with actual data size &lt;br&gt;
instead of user count, which was the underlying issue.&lt;/p&gt;

&lt;p&gt;Fixing the cleanup job removed a major source of waste, but the profile endpoint was still dragging both cost and latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #2: One endpoint doing too much
&lt;/h2&gt;

&lt;p&gt;The second hotspot was the profile endpoint which was heavily used throughout the portal.&lt;/p&gt;

&lt;p&gt;The profile endpoint had become one of the slowest and most expensive parts of the system. It was queried frequently, responded too slowly, and generated far too many database reads.&lt;/p&gt;

&lt;p&gt;The analysis revealed that the real issue was not one single bug, but several small inefficiencies that had accumulated over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What was going wrong
&lt;/h3&gt;

&lt;p&gt;Several small inefficiencies compounded into one expensive endpoint.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Too many duplicate requests
&lt;/h4&gt;

&lt;p&gt;When the profile page opened, multiple React components requested the same profile data at nearly the same time. Because there was no deduplication, several identical requests were sent in parallel.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Each request loaded more data than necessary
&lt;/h4&gt;

&lt;p&gt;The backend always loaded additional subcollections such as game stats and recent completed games, even though most components that requested profile data did not need them.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Maintenance tasks ran during normal user requests
&lt;/h4&gt;

&lt;p&gt;The endpoint also triggered cleanup jobs and daily event generation. Some of this work only needed to run once per day, but it was being checked on every request.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Extra network overhead on every call
&lt;/h4&gt;

&lt;p&gt;The frontend forced a fresh Firebase auth token before each API request, creating an unnecessary extra roundtrip to 3rd-party services.&lt;/p&gt;

&lt;h4&gt;
  
  
  5. No effective response caching
&lt;/h4&gt;

&lt;p&gt;Even if nothing had changed, the browser still downloaded the full profile response again.&lt;/p&gt;

&lt;h3&gt;
  
  
  How we fixed it
&lt;/h3&gt;

&lt;p&gt;Together with the AI assistant, I optimized the endpoint in several layers:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Reuse cached auth tokens
&lt;/h4&gt;

&lt;p&gt;I replaced getIdToken(true) with getIdToken(), allowing Firebase to use cached tokens until they actually expire.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Lazy loading
&lt;/h4&gt;

&lt;p&gt;Game stats and completed games were removed from the default profile response and moved to separate endpoints. They are now only fetched when the user opens those sections in the profile view.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Move maintenance off the hot path
&lt;/h4&gt;

&lt;p&gt;A &lt;code&gt;lastMaintenanceAt&lt;/code&gt; timestamp now ensures cleanup and daily event generation only run once per day.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Request deduplication and caching with ETags
&lt;/h4&gt;

&lt;p&gt;I added a short-lived in-memory cache on the frontend so simultaneous requests could reuse the first response instead of hitting the backend multiple times.&lt;/p&gt;

&lt;p&gt;Additionally, if the profile has not changed, the server now returns 304 Not Modified, so the browser can reuse its cached version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;The profile page became noticeably faster, backend latency dropped, and Firestore reads were reduced significantly.&lt;/p&gt;

&lt;p&gt;Instead of one endpoint doing five jobs on every request, it now does only the work that is actually needed.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: Reduced reads on the profile collection after applying multiple improvements&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the profile endpoint, one last expensive pattern remained: seed generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem #3: Random Seeds Were Surprisingly Expensive
&lt;/h3&gt;

&lt;p&gt;The final issue came from a feature that seemed harmless: random seed generation.&lt;/p&gt;

&lt;p&gt;A seed is a number used to initialize a game so that players share the same world state. The system organizes seeds into hourly, daily, and weekly pools.&lt;/p&gt;

&lt;h4&gt;
  
  
  What was going wrong
&lt;/h4&gt;

&lt;p&gt;Every backend request to retrieve a seed called getActivityWeights(), which computed selection weights based on multiple Firestore documents. Each seed in the pool was stored as a separate document.&lt;/p&gt;

&lt;p&gt;Depending on the pool size, this resulted in 8 to 50 Firestore reads per request.&lt;/p&gt;

&lt;p&gt;With ~200 daily users requesting seeds, this alone produced roughly 50k reads per day — effectively consuming the entire free tier budget.&lt;/p&gt;

&lt;h4&gt;
  
  
  How I fixed it
&lt;/h4&gt;

&lt;p&gt;The issue wasn’t the weighting logic itself, but how it was stored.&lt;/p&gt;

&lt;p&gt;Instead of computing weights by reading multiple documents on every request, I moved the computed state into the existing seedPools/{poolType} document, which was already being updated whenever a game finished.&lt;/p&gt;

&lt;p&gt;Now the system maintains a seedWeights map directly inside that document.&lt;/p&gt;

&lt;p&gt;When a seed is requested, the backend only reads this single document instead of fetching multiple entries from the pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;This reduced seed-related usage from ~50k reads per day down to ~2k reads per day.&lt;/p&gt;

&lt;p&gt;The logic stayed the same, but the read pattern collapsed from N documents per request to 1.&lt;/p&gt;

&lt;p&gt;After fixing all three issues, Firestore usage dropped back into free-tier limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to free tier
&lt;/h2&gt;

&lt;p&gt;The optimized database reads directly resulted in going back into the free tier of Firebase, as the image below indicates.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: The billing report shows that the daily costs went to zero after the optimizations&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In addition, the perceived performance improved is also visible in the HTTP API performance metrics. Most services respond within 100ms to 500ms, and the amount of requests of the profile resource was significantly reduced after the optimizations.&lt;/p&gt;

&lt;p&gt;I am very happy now that costs dropped back into the free tier, and the system felt fast again. And I believe my users can feel the difference as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;As discussed in earlier posts, AI code assistants help to ship and validate ideas fast. It is possible to create functioning and maintainable software at speed never seen before.&lt;/p&gt;

&lt;p&gt;However, it seems that AI-generated code often prioritizes working solutions over efficient ones. Human review is still needed to optimize resource consumption (and therefore costs), scaling, and performance - ideally before cost explosions or performance degradation.&lt;/p&gt;

&lt;p&gt;For me, AI coding assistance paired with human software engineering expertise is a game changer for the speed of shipping features and maintaining software systems.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>performance</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Built My Game Portal - Then the Firebase Bill Arrived</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Fri, 17 Apr 2026 15:27:00 +0000</pubDate>
      <link>https://dev.to/sebhoek/ai-built-it-fast-human-optimization-cut-the-costs-13on</link>
      <guid>https://dev.to/sebhoek/ai-built-it-fast-human-optimization-cut-the-costs-13on</guid>
      <description>&lt;p&gt;My AI-built browser game portal was growing. That was good news - until Firebase bills started rising and performance got worse.&lt;/p&gt;

&lt;p&gt;Now was the moment where I as a software engineer had to step in.&lt;/p&gt;

&lt;p&gt;With an increasing user base playing more and more games every day, I slipped out of the free usage tier for Firebase Storage which I use for persistency. &lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: The costs increased to about 10 USD per months with a clear upward trend&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Also, I noticed that the perceived performance of my HTTP services degraded over time.&lt;/p&gt;

&lt;p&gt;This is less than optimal. What happened? It probably had to do with how AI has set up the HTTP requests and database queries. &lt;/p&gt;

&lt;p&gt;How can I investigate the causes and what can I do to fix it? Let's dive into it!&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Finding the real bottlenecks&lt;/li&gt;
&lt;li&gt;Problem #1: A nightly job burning reads&lt;/li&gt;
&lt;li&gt;Problem #2: One endpoint doing too much&lt;/li&gt;
&lt;li&gt;Problem #3: Random seeds were surprisingly expensive&lt;/li&gt;
&lt;li&gt;Back to free tier&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Finding the real bottlenecks
&lt;/h2&gt;

&lt;p&gt;For me, observability is one of the most useful tools for finding and understanding problems.&lt;/p&gt;

&lt;p&gt;Rather than guessing, I used three sources of data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Cloud Billing Reports to see where costs came from&lt;/li&gt;
&lt;li&gt;Firestore Query Insights to identify expensive collections&lt;/li&gt;
&lt;li&gt;API latency metrics in Google Cloud Observability to spot slow endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the metrics revealed
&lt;/h3&gt;

&lt;p&gt;The billing report identified Firestore as the only contributor to the costs. And within Firestore, it was clear that some collections had too many reads for the daily active users of my gaming portal.&lt;/p&gt;

&lt;p&gt;With up to 1 million reads per day, my small system exceeded the free tier threshold of 50k reads per day by more than factor 10.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: Firestore usage showed too many reads and writes before the optimization&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Firestore Query Insights indicated that some collections like the profile, game completion and highscores were the main source of the reads.&lt;/p&gt;

&lt;p&gt;After having set up the HTTP API metrics in Google Cloud Observability, I could see that the profile resource was queried too many times and had a high latency, and that the same applied to the random seed generator resource.&lt;/p&gt;

&lt;p&gt;With this information, I could challenge my coding assistant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which parts of the code read those collections too often?&lt;/li&gt;
&lt;li&gt;Why is the profile resource slow and called so frequently?&lt;/li&gt;
&lt;li&gt;Why is the random seed generator so slow?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three areas stood out immediately: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a nightly cleanup job, &lt;/li&gt;
&lt;li&gt;the profile endpoint, &lt;/li&gt;
&lt;li&gt;and the random seed generator. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they were driving most of the cost and latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #1: A nightly job burning reads
&lt;/h2&gt;

&lt;p&gt;The first surprise came from a background job that users never even saw.&lt;/p&gt;

&lt;p&gt;The nightly cleanup function, written by the coding assistant, had an N+1 read pattern that scaled poorly with the number of profiles. At small scale I didn't notice it, but with real usage it became a major cost driver.&lt;/p&gt;

&lt;h3&gt;
  
  
  What was going wrong
&lt;/h3&gt;

&lt;p&gt;The job iterated over every profile document and then ran a subquery per profile to find old game starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// loads ALL profiles&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profilesSnapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROFILE_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; 

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileDoc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;profilesSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;profileDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GAMESTATS_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// separate query per profile&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the job always performed one collection scan plus N additional subqueries, where N is the number of profiles — even if most profiles had nothing to clean up.&lt;/p&gt;

&lt;p&gt;In practice, with ~500 profiles but only ~30 containing stale data, the job still executed ~501 reads instead of ~30 relevant reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  How we fixed it
&lt;/h3&gt;

&lt;p&gt;We replaced the per-profile loop with a collection group query that directly targets only the documents that need cleanup::&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectionGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GAMESTARTS_COLLECTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;oldGameStarts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shifts the cost from being proportional to the number of profiles to being proportional to the number of matching documents.&lt;/p&gt;

&lt;p&gt;In the same example, that reduced the work from ~501 reads down to ~30.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;This single change removed a large portion of the Firestore cost baseline. It also made the cleanup job scale with actual data size &lt;br&gt;
instead of user count, which was the underlying issue.&lt;/p&gt;

&lt;p&gt;Fixing the cleanup job removed a major source of waste, but the profile endpoint was still dragging both cost and latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #2: One endpoint doing too much
&lt;/h2&gt;

&lt;p&gt;The second hotspot was the profile endpoint which was heavily used throughout the portal.&lt;/p&gt;

&lt;p&gt;The profile endpoint had become one of the slowest and most expensive parts of the system. It was queried frequently, responded too slowly, and generated far too many database reads.&lt;/p&gt;

&lt;p&gt;The analysis revealed that the real issue was not one single bug, but several small inefficiencies that had accumulated over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What was going wrong
&lt;/h3&gt;

&lt;p&gt;Several small inefficiencies compounded into one expensive endpoint.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Too many duplicate requests
&lt;/h4&gt;

&lt;p&gt;When the profile page opened, multiple React components requested the same profile data at nearly the same time. Because there was no deduplication, several identical requests were sent in parallel.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Each request loaded more data than necessary
&lt;/h4&gt;

&lt;p&gt;The backend always loaded additional subcollections such as game stats and recent completed games, even though most components that requested profile data did not need them.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Maintenance tasks ran during normal user requests
&lt;/h4&gt;

&lt;p&gt;The endpoint also triggered cleanup jobs and daily event generation. Some of this work only needed to run once per day, but it was being checked on every request.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Extra network overhead on every call
&lt;/h4&gt;

&lt;p&gt;The frontend forced a fresh Firebase auth token before each API request, creating an unnecessary extra roundtrip to 3rd-party services.&lt;/p&gt;

&lt;h4&gt;
  
  
  5. No effective response caching
&lt;/h4&gt;

&lt;p&gt;Even if nothing had changed, the browser still downloaded the full profile response again.&lt;/p&gt;

&lt;h3&gt;
  
  
  How we fixed it
&lt;/h3&gt;

&lt;p&gt;Together with the AI assistant, I optimized the endpoint in several layers:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Reuse cached auth tokens
&lt;/h4&gt;

&lt;p&gt;I replaced getIdToken(true) with getIdToken(), allowing Firebase to use cached tokens until they actually expire.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Lazy loading
&lt;/h4&gt;

&lt;p&gt;Game stats and completed games were removed from the default profile response and moved to separate endpoints. They are now only fetched when the user opens those sections in the profile view.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Move maintenance off the hot path
&lt;/h4&gt;

&lt;p&gt;A &lt;code&gt;lastMaintenanceAt&lt;/code&gt; timestamp now ensures cleanup and daily event generation only run once per day.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Request deduplication and caching with ETags
&lt;/h4&gt;

&lt;p&gt;I added a short-lived in-memory cache on the frontend so simultaneous requests could reuse the first response instead of hitting the backend multiple times.&lt;/p&gt;

&lt;p&gt;Additionally, if the profile has not changed, the server now returns 304 Not Modified, so the browser can reuse its cached version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;The profile page became noticeably faster, backend latency dropped, and Firestore reads were reduced significantly.&lt;/p&gt;

&lt;p&gt;Instead of one endpoint doing five jobs on every request, it now does only the work that is actually needed.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: Reduced reads on the profile collection after applying multiple improvements&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the profile endpoint, one last expensive pattern remained: seed generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem #3: Random Seeds Were Surprisingly Expensive
&lt;/h3&gt;

&lt;p&gt;The final issue came from a feature that seemed harmless: random seed generation.&lt;/p&gt;

&lt;p&gt;A seed is a number used to initialize a game so that players share the same world state. The system organizes seeds into hourly, daily, and weekly pools.&lt;/p&gt;

&lt;h4&gt;
  
  
  What was going wrong
&lt;/h4&gt;

&lt;p&gt;Every backend request to retrieve a seed called getActivityWeights(), which computed selection weights based on multiple Firestore documents. Each seed in the pool was stored as a separate document.&lt;/p&gt;

&lt;p&gt;Depending on the pool size, this resulted in 8 to 50 Firestore reads per request.&lt;/p&gt;

&lt;p&gt;With ~200 daily users requesting seeds, this alone produced roughly 50k reads per day — effectively consuming the entire free tier budget.&lt;/p&gt;

&lt;h4&gt;
  
  
  How we fixed it
&lt;/h4&gt;

&lt;p&gt;The issue wasn’t the weighting logic itself, but how it was stored.&lt;/p&gt;

&lt;p&gt;Instead of computing weights by reading multiple documents on every request, we moved the computed state into the existing seedPools/{poolType} document, which was already being updated whenever a game finished.&lt;/p&gt;

&lt;p&gt;Now the system maintains a seedWeights map directly inside that document.&lt;/p&gt;

&lt;p&gt;When a seed is requested, the backend only reads this single document instead of fetching multiple entries from the pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;This reduced seed-related usage from ~50k reads per day down to ~2k reads per day.&lt;/p&gt;

&lt;p&gt;The logic stayed the same, but the read pattern collapsed from N documents per request to 1.&lt;/p&gt;

&lt;p&gt;After fixing all three issues, Firestore usage dropped back into free-tier limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to free tier
&lt;/h2&gt;

&lt;p&gt;The optimized database reads directly resulted in going back into the free tier of Firebase, as the image below indicates.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Image: The billing report shows that the daily costs went to zero after the optimizations&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In addition, the perceived performance improved is also visible in the HTTP API performance metrics. Most services respond within 100ms to 500ms, and the amount of requests of the profile resource was significantly reduced after the optimizations.&lt;/p&gt;

&lt;p&gt;I am very happy now that costs dropped back into the free tier, and the system felt fast again. And I believe my users can feel the difference as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;As discussed in earlier posts, AI code assistants help to ship and validate ideas fast. It is possible to create functioning and maintainable software at speed never seen before.&lt;/p&gt;

&lt;p&gt;However, it seems that AI-generated code often prioritizes working solutions over efficient ones. Human review is still needed to optimize resource consumption (and therefore costs), scaling, and performance - ideally before cost explosions or performance degradation.&lt;/p&gt;

&lt;p&gt;For me, AI coding assistance paired with human software engineering expertise is a game changer for the speed of shipping features and maintaining software systems.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>performance</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Retention Over Clicks: A Surprising Lesson from Browser Game Analytics</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Wed, 04 Mar 2026 16:30:17 +0000</pubDate>
      <link>https://dev.to/sebhoek/retention-over-clicks-a-surprising-lesson-from-browser-game-analytics-3o86</link>
      <guid>https://dev.to/sebhoek/retention-over-clicks-a-surprising-lesson-from-browser-game-analytics-3o86</guid>
      <description>&lt;h2&gt;
  
  
  Retention Matters More Than Traffic
&lt;/h2&gt;

&lt;p&gt;In this series, I discuss various aspects of developing my browser game portal Pausen Games.&lt;/p&gt;

&lt;p&gt;For this portal, it is crucial to find users, to keep them engaged, and for them to come back regularly. I usually use the terms acquisition, engagement time and retention to describe their behavior.&lt;/p&gt;

&lt;p&gt;The hard lesson I learned: The way the users are acquired determines their engagement time and retention. I need to find those users who are more likely to enjoy using my website, even if that means higher efforts during acquisition and fewer total number of users.&lt;/p&gt;

&lt;p&gt;In this post I will dive into the details of this mechanic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why paid traffic might not find the users you want
&lt;/h2&gt;

&lt;p&gt;For acquisition, I am combining organic search (SEO) and paid traffic. I am still learning and experimenting and trying out different ideas.&lt;/p&gt;

&lt;p&gt;For paid traffic, I can use an ad network like Google Ads, assign a daily budget and select the region and languages my ads should be targeting. &lt;/p&gt;

&lt;p&gt;In addition, for the bidding strategy's incentive, I either aim for &lt;em&gt;clicks&lt;/em&gt;, or, with additional implementation effort within my website, define and optimize for a &lt;em&gt;conversion value&lt;/em&gt; which in my case would be determined by how many games a visitor plays.&lt;/p&gt;

&lt;p&gt;Then naturally, the ad network will try to maximise the conversion goal with the given budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For click-based strategy, find as many users with the lowest cost-per-click as possible&lt;/li&gt;
&lt;li&gt;For conversion-value-based strategy, still find the most users possible with the lowest cost-per-click, but also consider their conversion value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To get me started, I selected random regions in the world and chose the conversion value strategy, hoping that the ad network would find me many users who would enjoy using my game portal.&lt;/p&gt;

&lt;p&gt;Unfortunately, this didn't quite happen. Over a longer period of time, my ad budget was used to direct many users to my website, but most of them would never come back a second time. &lt;/p&gt;

&lt;p&gt;The average weekly retention figures were discouraging. Was my game portal really so bad? &lt;/p&gt;

&lt;h2&gt;
  
  
  How Acquisition Context Shapes Player Behavior
&lt;/h2&gt;

&lt;p&gt;Using the user analytics capabilities I discussed in my last post, I could segment my users along different properties such as region, language, used platform etc.&lt;/p&gt;

&lt;p&gt;By filtering these properties I could identify three different groups as illustrated in the weekly retention charts below:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Group 1 shows short-term engagement and low multi-day return. This is the biggest group&lt;/li&gt;
&lt;li&gt;Group 2 show repeated return and is progression-oriented. We see retention rates of whopping 60%! By digging into detailed user data, I could even find a few individual users who come back on a daily basis for weeks and play the same game over and over again (yay! someone seems to enjoy my stuff!)&lt;/li&gt;
&lt;li&gt;Group 3 is somewhere between the other two groups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1z9h6x4nyk45vfh9sc3a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1z9h6x4nyk45vfh9sc3a.png" alt="Short-term engagement and low multi-day return" width="800" height="131"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Group 1: Short-term engagement and low multi-day return&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx168hkvqa2e3l73d4se.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx168hkvqa2e3l73d4se.png" alt="Repeated returns and progression-oriented" width="800" height="151"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Group 2: Repeated returns and progression-oriented&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwog4y7g78fta6r0w5gl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwog4y7g78fta6r0w5gl.png" alt="Somewhat engaged and returning" width="800" height="143"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Group 3: Somewhat engaged and returning&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now comes the really interesting part:&lt;/strong&gt; These groups seemed to correlate with how much I was paying for their acquisition! &lt;/p&gt;

&lt;p&gt;If I paid a lot for acquiring a user, they were more likely to engage with my game portal and come back over days and weeks.&lt;/p&gt;

&lt;p&gt;If I attracted users with low cost-per-click, they were more likely to engage less with my game portal and they didn't come back much over days and weeks.&lt;/p&gt;

&lt;p&gt;This made it clear that optimizing for low acquisition costs would jeopardize my engagement numbers.&lt;/p&gt;

&lt;p&gt;Applying what I just learned, I adjusted my acquisition strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Retention Insights to Guide Strategy
&lt;/h2&gt;

&lt;p&gt;For me, weekly retention and multi-session engagement matter far more than total visits. I prefer my users to be active and have fun on my gaming portal.&lt;/p&gt;

&lt;p&gt;Now that I learned that retention varies by acquisition source and campaign cost (not by the people themselves), I could adjust my strategy to find users.&lt;/p&gt;

&lt;p&gt;In my ads network, I need to only target those users that show the best engagement figures. This is probably very specific to the kind of product offered, but it is a mix of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Optimize for intent not volume. I need to match expectations created by ad assets with actual product (advertising free beer might create many cheep clicks but high-churn users)&lt;/li&gt;
&lt;li&gt;Combine targeted regions, platforms, languages according to what I find to be the best working audience for my product&lt;/li&gt;
&lt;li&gt;Separate high and low cost-per-click audiences by setting up different campaigns and budget. This will make it easier to identify useful patterns and to avoid optimizing for the wrong audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I am aware that for someone with a marketing background, this might not be super new. But for me as solo indie dev, this was quite relevant, surprising and new.&lt;/p&gt;

&lt;p&gt;Quantitative user analytics enables me to identify the audience which enjoys my product most. The way I configure my ad campaign determines which audience I attract. Matching both yields in making me happy when I look at the statistics, because happy users are what drives me.&lt;/p&gt;

&lt;p&gt;I'd be interested to know if you have similar or contrary experiences - feel free to leave a comment.&lt;/p&gt;

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

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Google Analytics, SaaS, or self-hosted? How I chose my analytics stack</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Fri, 13 Feb 2026 14:58:26 +0000</pubDate>
      <link>https://dev.to/sebhoek/google-analytics-saas-or-self-hosted-how-i-chose-my-analytics-stack-271g</link>
      <guid>https://dev.to/sebhoek/google-analytics-saas-or-self-hosted-how-i-chose-my-analytics-stack-271g</guid>
      <description>&lt;h2&gt;
  
  
  Why I need user analytics
&lt;/h2&gt;

&lt;p&gt;In previous parts of this series, I already introduced Pausen Games, my little browser game portal.&lt;/p&gt;

&lt;p&gt;When developing any digital end-user product, the core questions for me are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many Daily Active Users (DAU) do I have?&lt;/li&gt;
&lt;li&gt;What is their demographics (country, browser language)?&lt;/li&gt;
&lt;li&gt;What technology do they use (desktop/mobile, OS, browser)?&lt;/li&gt;
&lt;li&gt;How engaged are they (session length, retention)?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this specific product, the following additional questions are relevant to me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which games do my users play most?&lt;/li&gt;
&lt;li&gt;What are my games' completion rates?&lt;/li&gt;
&lt;li&gt;Which other features like profile view, highscore view etc. are they using?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To answer these questions, user analytics platforms can be used to track and aggregate user behavior in event databases and to visualize relevant metrics in beautiful dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining requirements before picking a tool
&lt;/h2&gt;

&lt;p&gt;To understand my choice, it is relevant to discuss my context and preferences.&lt;/p&gt;

&lt;p&gt;In this project, I am a solo developer with limited budget and limited time. Also, I don't want to place any cookie banners on my portal because I believe this distracts and annoys users.&lt;/p&gt;

&lt;p&gt;In summary, here is what is important to me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimal consent friction in my product - no data sharing with 3rd-parties&lt;/li&gt;
&lt;li&gt;Access to raw events&lt;/li&gt;
&lt;li&gt;Ability to define custom events&lt;/li&gt;
&lt;li&gt;Ability visualize custom metrics&lt;/li&gt;
&lt;li&gt;Low operational overhead&lt;/li&gt;
&lt;li&gt;Reasonable cost at low to medium traffic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now that the why and what is clearer, we can look at different solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default choice: Google Analytics
&lt;/h2&gt;

&lt;p&gt;Google Analytics appears to be the obvious choice when it comes to tracking user analytics for websites and mobile applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it’s attractive
&lt;/h3&gt;

&lt;p&gt;Google Analytics is popular because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it is free to use even at scale&lt;/li&gt;
&lt;li&gt;it is pretty mature&lt;/li&gt;
&lt;li&gt;it comes with a useful user interface with default charts and which allows to add custom charts (with limitations). &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In addition, it integrates well into the rest of Google's ecosystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can use Google Looker Studio (for free) to connect to Google Analytics to create custom dashboards&lt;/li&gt;
&lt;li&gt;You can export the raw time series data to Google's BigQuery with a standard connector, create custom data tables using scheduled queries and connect Google Looker Studio directly to your custom data tables for complete flexibility. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From my experience, using Looker Studio directly on Google Analytics exhausts the daily query quota quickly. But even with a moderate amount of events collected over a few years and a significant number of daily scheduled queries, you can stay within the free tier of Google BigQuery, which makes this my preferred option.&lt;/p&gt;

&lt;p&gt;The diagram below summarizes the different options to visualize and analyze user analytics events with Google Analytics.&lt;/p&gt;

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

&lt;p&gt;The internet is full of (more or less) beautiful and free Looker Studio dashboards for Google Analytics as shown in the example below. They can be used as an inspiration and as a starting point for custom dashboards.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F80kof1ovs8h0zifp7xim.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F80kof1ovs8h0zifp7xim.png" alt="One of many free Looker Studio templates" width="800" height="604"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This example is randomly picked from &lt;a href="https://www.catchr.io/template/looker-studio-templates/google-analytics-4-quick-view" rel="noopener noreferrer"&gt;some people on the internet&lt;/a&gt;; I am not affiliated.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it doesn’t fully fit my needs
&lt;/h3&gt;

&lt;p&gt;In other projects, I use Google Analytics both for web applications and for mobile applications since many years, and it serves me well. I applied the setup with Google BigQuery and Looker Studio and I have created many insightful charts which are still driving business decisions.&lt;/p&gt;

&lt;p&gt;The main reason why I find the use of Google Analytics problematic for new projects is the friction that it creates when my website has to ask to user to allow collecting and correlating their data by a third-party provider.&lt;/p&gt;

&lt;p&gt;Users who choose to not participate create gaps in my user analytics data, and I believe that many users would decide to do so.&lt;/p&gt;

&lt;p&gt;Therefore I was looking for a solution where the collected data is fully under my control, not shared with any third-party, but which still has the power and flexibility of a data warehouse with custom charts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Hosted analytics SaaS
&lt;/h2&gt;

&lt;p&gt;I don't want to go into too much detail here to compare individual offers. The tools I looked at include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Plausible (hosted)&lt;/li&gt;
&lt;li&gt;Fathom analytics&lt;/li&gt;
&lt;li&gt;Simple Analytics&lt;/li&gt;
&lt;li&gt;PostHog Cloud&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Their benefits
&lt;/h3&gt;

&lt;p&gt;They all have more or less the following in common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Easy to set up&lt;/li&gt;
&lt;li&gt;Trial period or free tier to get started&lt;/li&gt;
&lt;li&gt;No infrastructure to manage&lt;/li&gt;
&lt;li&gt;Privacy-friendly default&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Their limitations
&lt;/h3&gt;

&lt;p&gt;Coming from my previously described setup with a custom data warehouse (Google BigQuery) and a custom charting layer (Google DataStudio), I found them all to have the following drawbacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sooner or later there will be costs, often growing as traffic grows.&lt;/li&gt;
&lt;li&gt;There is limited or paid access to raw data for further processing.&lt;/li&gt;
&lt;li&gt;The existing dashboards are opinionated and it might be tedious to visualize my KPIs the way I want.&lt;/li&gt;
&lt;li&gt;And still, the date is not with me but with some third-party which I need to explain to my users.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To fulfill my needs of accessing and transforming the raw data so I can create my custom charts and not sharing the data with 3rd-parties, I'd have to get my hands a bit dirty it seemed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: Self-hosted analytics
&lt;/h2&gt;

&lt;p&gt;By self-hosting a user analytics solution, I can fulfill my requirements. &lt;/p&gt;

&lt;p&gt;I am fully in control over the selected data. &lt;/p&gt;

&lt;p&gt;I can predict and control the costs of the approach.&lt;/p&gt;

&lt;p&gt;I can freely transform the data to perform deeper analysis and custom visualizations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I chose Plausible
&lt;/h3&gt;

&lt;p&gt;While evaluating Plausible, I found that &lt;a href="https://plausible.io/open-source-website-analytics" rel="noopener noreferrer"&gt;they offer an open source solution&lt;/a&gt; which I can download and run on my own infrastructure. &lt;/p&gt;

&lt;p&gt;I liked the simple and straight-forward programming model of collecting the events which works across programming languages and also include mobile applications. Tracking multiple applications and adding custom events is very easy.&lt;/p&gt;

&lt;p&gt;It seemed to be lightweight enough to run on a small virtual machine. &lt;/p&gt;

&lt;p&gt;I am not going into the details of self-hosting Plausible. If you you interested, let me know and I can create a dedicated post about it. If you want to get started, check out their &lt;a href="https://github.com/plausible/community-edition?tab=readme-ov-file" rel="noopener noreferrer"&gt;Getting Started repository with a handy Docker compose file&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;By self-hosting the the solution and therefore also the collected data, I implement a privacy-friendly approach where I don't share any data with 3rd-parties.&lt;/p&gt;

&lt;p&gt;Out of the box, Plausible provides a few useful charts, but again I had the appetite to access to the raw data, transform it into different shapes, to calculate additional KPIs and to visualize them. &lt;/p&gt;

&lt;p&gt;The screenshot below shows how my self-hosted Plausible instance provides basic insights into how users find and use my gaming portal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbdce3ymq5qrdg95bg7fp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbdce3ymq5qrdg95bg7fp.png" alt="Actual user analytics with self-hosted Plausible" width="800" height="730"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With this setup in place, I was ready to take my user analytics to the level I envisioned.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting a data warehouse and custom charts
&lt;/h3&gt;

&lt;p&gt;To visualize my custom KPIs in Google's Looker Studio, I had to find a way to export the raw data in a data source Looker Studio can read out of the box and which I can use without any costs.&lt;/p&gt;

&lt;p&gt;Finally, I found a project where I could apply the knowledge I acquired for my (already expired) Google Cloud Architect certification!&lt;/p&gt;

&lt;p&gt;Here is my approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;(1) From my application, user analytics events are sent to my self-hosted Plausible instance.&lt;/li&gt;
&lt;li&gt;(2) In my VM, set up a cron job that exports yesterday's raw analytics events into a CSV file.&lt;/li&gt;
&lt;li&gt;(3) After the export, the CSV file is copied to a Google Bucket (cloud storage).&lt;/li&gt;
&lt;li&gt;(4) A Google Cloud Function detects the arrival of the file and appends its content into an existing time-series table in Google BigQuery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While I could now set up Looker Studio directly to the raw events table in Google BigQuery, I found the schema a bit too complex to visualize easily.&lt;/p&gt;

&lt;p&gt;(5) Therefore I set up &lt;a href="https://docs.cloud.google.com/bigquery/docs/scheduling-queries" rel="noopener noreferrer"&gt;scheduled queries&lt;/a&gt; in BigQuery which extract the raw data and transform it into a schema that is much easier to visualize in Looker Studio. &lt;/p&gt;

&lt;p&gt;In these scheduled queries, I could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;omit irrelevant columns,&lt;/li&gt;
&lt;li&gt;flatten nested attributes, for example from events,&lt;/li&gt;
&lt;li&gt;pre-calculate relevant columns, for example the session number of a specific user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(6) Finally, I could connect Google Looker Studio directly to the transformed data in my BigQuery and define the charts I wanted. &lt;/p&gt;

&lt;p&gt;The overall approach is depicted in the diagram below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbsq953hv0053o64kp1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0cbsq953hv0053o64kp1.png" alt="Using a self-hosted Plausible, BigQuery and Looker Studio" width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have to admit that using my favorite LLM chat has significantly accelerated the design and implementation of this architecture. &lt;/p&gt;

&lt;p&gt;Creating complex, somewhat correct and efficient SQL queries for BigQuery was something that had cost me hours and days in the past. &lt;/p&gt;

&lt;p&gt;With the tools available today, this is still nothing that works on the first attempt, but the overall speed of bootstrapping queries and understanding and debugging problems is at a different order of magnitude.&lt;/p&gt;

&lt;p&gt;With this setup in place, I could start defining the charts I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can answer now (that I couldn’t before)
&lt;/h2&gt;

&lt;p&gt;In addition to the charts that Plausible provides by default, I wanted to answer the following questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From the daily active users (DAU), how many are new users and how many are returning users from previous days?&lt;/li&gt;
&lt;li&gt;For every day, how many games are played in total?&lt;/li&gt;
&lt;li&gt;In average, how many games are users playing per day?&lt;/li&gt;
&lt;li&gt;What is the completion rates of my games? How many games are actually finished compared to how many were started?&lt;/li&gt;
&lt;li&gt;And most difficult: What is my weekly retention rate? (Meaning: For every user cohort that joined a specific week, what are the return rates for the second, third, and the following weeks?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now answering these questions just means creating a custom scheduled query that produces the time-series events with the relevant data in BiqQuery, scheduling this query for a daily (or weekly) run, and connecting a chart in Looker Studio to visualize the data on a timeline.&lt;/p&gt;

&lt;p&gt;Below you can find two real-life examples of charts I defined to answer the above questions. As you can see, some metrics still leave a lot of room for inmprovements :) (which I will be talking about in a different post).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw5eeduid7s5npv6329h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuw5eeduid7s5npv6329h.png" alt="Looker Studio Charts for custom metrics of daily usage" width="800" height="944"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fofmmq80h5m0c4eoj0rf0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fofmmq80h5m0c4eoj0rf0.png" alt="Looker Studio Charts for weekly retention of users from Brazil" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This flexibility however comes at some costs as I will discuss in the following chapter.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tradeoff I accepted
&lt;/h3&gt;

&lt;p&gt;Self-hosting a user analytics stack does not come for free. Here are some of the costs (I am willing to accept):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Running and maintaining a VM costs money and time. Currently, I still pay around EUR 6 per month for a VM with 8GB RAM, for vCPUs and 120GB disk. I had to increase the VM and disk after a while because the smaller ran out of capacity.&lt;/li&gt;
&lt;li&gt;There is a considerable amount of work to be invested for the initial setup of the stack (even with the help of smart and confident AI chats).&lt;/li&gt;
&lt;li&gt;It is essential to spend a few thoughts security considerations. A self-managed VM exposed to the internet should not go without solid protection. &lt;/li&gt;
&lt;li&gt;I am responsible for data backups and regularly updating the stack. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Overall, and that's my conclusion, spending the additional effort to set up a self-hosted user analytics solution and connect it to a managed data warehouse and charting tool is absolutely worth it for me. &lt;/p&gt;

&lt;p&gt;I believe I achieved my goals, I had fun setting this up and I might have learned something on the way. &lt;/p&gt;

&lt;p&gt;Although I have some monthly costs and some maintenance work, the additional insights I gain into user behavior and the fact that I don't need to share user analytics data with 3rd-parties outweigh the drawbacks for me.&lt;/p&gt;

&lt;p&gt;User analytics gives me the insights I need to understand the weaknesses of my product and the impact of new features I released. &lt;/p&gt;

&lt;p&gt;In future posts, I’ll talk about how I’m continuing to work on my Pausengames portal.&lt;/p&gt;

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

</description>
      <category>analytics</category>
      <category>architecture</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I built a browser game portal using AI and what I had to fix myself</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Wed, 14 Jan 2026 16:09:51 +0000</pubDate>
      <link>https://dev.to/sebhoek/how-i-built-a-browser-game-portal-using-ai-and-what-i-had-to-fix-myself-2din</link>
      <guid>https://dev.to/sebhoek/how-i-built-a-browser-game-portal-using-ai-and-what-i-had-to-fix-myself-2din</guid>
      <description>&lt;p&gt;I created a game portal in the browser to see how fast I am using AI code generators. Today I have a working product, many daily users and five playable games. This series describes my journey.&lt;/p&gt;

&lt;p&gt;In this article, I describe how I got started using an AI code generator. I share my early successes and later struggles with the generated code and my workflow. &lt;/p&gt;

&lt;h2&gt;
  
  
  Why I chose a browser-based vibe-coding tool and React
&lt;/h2&gt;

&lt;p&gt;The vibe-coding tool I started with allows quickly creating running web applications based on an initial prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I want to build a puzzle games website where people 
can play different brain teasers and logic puzzles. 
Users should be able to create accounts, track their scores, 
and compete on leaderboards. 
Use react and vite.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately chose a tech stack I am somewhat familiar with so I can judge the quality of the generated code. And it was pretty impressive!&lt;/p&gt;

&lt;p&gt;Here is what I was impressed with during my first experiments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first version was already pretty good, both visually and technically.&lt;/li&gt;
&lt;li&gt;The tool understands my prompts and executes them quite nicely.&lt;/li&gt;
&lt;li&gt;The tool looks like VSCode in the browser and allows switching between source code and the running web application.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  First successes
&lt;/h2&gt;

&lt;p&gt;As mentioned, the game portal was running quickly in the browser AI coding tool. I could adjust the overall appearance by vaguely describing my preferences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use different colors. It should be bright and friendly.
Also, make sure it looks good on mobile screens.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I think the colors, game logos and texts chosen by the AI code generator are pretty good as the screenshot below suggests.&lt;/p&gt;

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

&lt;p&gt;The first games that were added by the code generator without me specifically asking for it were a memory game and minesweeper. Both games worked instantly and only needed minor adjustments, for example regarding colors, the game buttons and the layout for different screen sizes - all done by using non-technical prompts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add buttons for restarting the game and starting the 
game of the day. While all games are randomly initialized, 
the game of the day should be the same game for all users.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code generator invented a concept using a seed number and implemented a pseudo-randomized seed function based on the current date. This worked like a charm!&lt;/p&gt;

&lt;p&gt;Adding variations of the game by introducing different sizes of the game field again was just a matter of asking the AI to just do it. I was truly impressed!&lt;/p&gt;

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

&lt;p&gt;These early wins worked well because the games were simple. That changed as soon as I tried to build more complex games.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI struggled: complex games &amp;amp; visuals
&lt;/h2&gt;

&lt;p&gt;The AI code generator consistently produced React game components with roughly the following structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import React from "react";
...

// types and constants

export function Game({seed, gridSize: initialSize}: GameProps) {
  // state hooks
  const [isRunning, setIsRunning] = useState(false);
  // ...

  // set up grid
  const initializeGame = () =&amp;gt; {
    // ...
  }

  // side effects
  useEffect(() =&amp;gt; { ...  }, []);
  // ...

  // handlers
  const onNewGame = () =&amp;gt; { ... };

  // JSX
  return (
    &amp;lt;div className="..."&amp;gt;...&amp;lt;/div&amp;gt;
  );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the game gets more complicated, this immediately creates the following problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The code of the component is pretty long and hard to read.&lt;/li&gt;
&lt;li&gt;The code block has too many responsibilities - game logic and presentation logic are mixed into one code block.&lt;/li&gt;
&lt;li&gt;Game logic could not be tested in isolation, for example by writing a unit test.&lt;/li&gt;
&lt;li&gt;Code cannot be reused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All this makes the game and the portal harder to maintain. Little bugs are harder to fix both for me and the AI code generator.&lt;/p&gt;

&lt;p&gt;I learned this the hard way when creating a game where the user is asked to connect a grid of pipes:&lt;/p&gt;

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

&lt;p&gt;You can try the Pipes game here: &lt;a href="https://pausengames.com/en/waterpipe" rel="noopener noreferrer"&gt;https://pausengames.com/en/waterpipe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The initial version of the game did not work out of the box. The game had the following issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating an initial solution as the starting point of a game did not work.&lt;/li&gt;
&lt;li&gt;Detection of when the player won the game did not work.&lt;/li&gt;
&lt;li&gt;Visualizing the connected pipes in a different color did not work.&lt;/li&gt;
&lt;li&gt;Drawing the pipes with an outline stroke using SVG did not work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My way of approaching and resolving the issues successfully was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask the code generator to refactor the component. 

&lt;ul&gt;
&lt;li&gt;Separate the game logic into another file. Create an proper TypeScript class with state and methods.&lt;/li&gt;
&lt;li&gt;Separate the solution generator into a separate file.&lt;/li&gt;
&lt;li&gt;Create a reusable component for the game buttons and use it across all games.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Start reading and actually understanding the generated code.

&lt;ul&gt;
&lt;li&gt;At this point I switched from the browser-based code generator to a general-purpose LLM chat.&lt;/li&gt;
&lt;li&gt;I asked the chat about typical data structures and algorithmic approaches for the problems I needed to solve.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Write my own implementation of the game logic and the solution generator

&lt;ul&gt;
&lt;li&gt;I didn't see a way to avoid actually understanding what I was doing here.&lt;/li&gt;
&lt;li&gt;I could use the LLM chat to quickly learn the data structures and algorithms needed. No need to read a paper or some university slides!&lt;/li&gt;
&lt;li&gt;I could use the LLM to create implementations the way I needed it and to have conversations about alternative implementations.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Regarding the SVGs to render the pipe, I saw no alternative to again work closely and iteratively with a LLM chat to create the implementation the way I liked. &lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways and conclusions
&lt;/h2&gt;

&lt;p&gt;Using a vibe coding tools was fun and led to first results quickly! It created a prototype that was ready for testing with actual users.&lt;/p&gt;

&lt;p&gt;But quickly I realized that AI code generators still seem to have limitations. &lt;/p&gt;

&lt;p&gt;For me it was critical to jump in where the AI code generator failed. Together, we could refactor and simplify the code. With a general purpose LLM chat, I could find an implementation that was correct, reliable and maintainable.&lt;/p&gt;

&lt;p&gt;Even in the age of AI code generators, good engineering practices like clean code, test automation and thoughtful architecture still matter - maybe even more than ever.&lt;/p&gt;

&lt;p&gt;AI can help me (and probably you) code faster, but only with my engineering judgement I can build maintainable, stable, secure software.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I write about next?
&lt;/h2&gt;

&lt;p&gt;For the next post in this series, I’m considering diving deeper into one of these areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security considerations for browser-based games&lt;/strong&gt;
(cheating, attacks, runaway cloud costs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-cost end-to-end architecture&lt;/strong&gt;
(from AI-generated React code to a deployable, maintainable production setup)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-compliant user analytics&lt;/strong&gt;
(how I measure player behavior without sharing user data with 3rd-parties)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acquisition&lt;/strong&gt;
(how people actually find and start playing the games with a limited budget)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let me know which one you’d find most useful.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>ai</category>
    </item>
    <item>
      <title>This is what I learned from vibe-coding five browser games</title>
      <dc:creator>Seb Hoek</dc:creator>
      <pubDate>Wed, 07 Jan 2026 08:07:07 +0000</pubDate>
      <link>https://dev.to/sebhoek/this-is-what-i-learned-from-vibe-coding-five-browser-games-gmp</link>
      <guid>https://dev.to/sebhoek/this-is-what-i-learned-from-vibe-coding-five-browser-games-gmp</guid>
      <description>&lt;p&gt;Hey, this is my first post.&lt;/p&gt;

&lt;p&gt;Last year I started using a vibe coding platform to quickly create a browser-based game portal and built five games. While I could see the first results quickly, I also encountered interesting challenges on my way to setting this up properly as a product for real users&lt;/p&gt;

&lt;p&gt;Today I have something running with real users but I have plans to extend it further. &lt;/p&gt;

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

&lt;p&gt;I plan to go into details in future posts and I can think of the following topics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My observations from coding and maintaining a project using an AI code assist. How can I keep confidence and quality?&lt;/li&gt;
&lt;li&gt;The tech stack I used for my 3-tier web application. Can I keep it low-cost but ready to scale?&lt;/li&gt;
&lt;li&gt;My approach to user analytics. How can I avoid Google Analytics and create insightful charts about the behavior of my users? &lt;/li&gt;
&lt;li&gt;Security considerations: How to protect from cheaters as well as from run-away cloud costs and other attacks?&lt;/li&gt;
&lt;li&gt;User acquisition: How can I attract new users with low budget and limited time using paid marketing and SEO? (this is still a question which is quite unanswered)&lt;/li&gt;
&lt;li&gt;User retention: How to keep my users on my site and how do
I make them come back regularly?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also let me know what you are interested in and I am happy to talk about it.&lt;/p&gt;

&lt;p&gt;If you want to check it out yourself: &lt;a href="https://pausengames.com" rel="noopener noreferrer"&gt;https://pausengames.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;(Disclaimer: This text was written without the help of any LLM.)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
