<?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>Forem: Yitao</title>
    <description>The latest articles on Forem by Yitao (@yitao).</description>
    <link>https://forem.com/yitao</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%2F3740904%2F867f72d1-0507-4a83-a1fd-a566a0f949bb.jpg</url>
      <title>Forem: Yitao</title>
      <link>https://forem.com/yitao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/yitao"/>
    <language>en</language>
    <item>
      <title>Next.js 15 + Supabase: I Accidentally Blew Past My Quota by 1000% (and How “Local‑First” Saved It)</title>
      <dc:creator>Yitao</dc:creator>
      <pubDate>Fri, 30 Jan 2026 06:25:47 +0000</pubDate>
      <link>https://forem.com/yitao/nextjs-15-supabase-i-accidentally-blew-past-my-quota-by-1000-and-how-local-first-saved-it-1leh</link>
      <guid>https://forem.com/yitao/nextjs-15-supabase-i-accidentally-blew-past-my-quota-by-1000-and-how-local-first-saved-it-1leh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: My “perfectly reasonable” real-time architecture for an online party game turned into a billing horror story — first with Supabase Realtime broadcasts, then with Redis + polling. The fix wasn’t “optimize the server.” It was &lt;strong&gt;stop needing the server&lt;/strong&gt; for the common use case.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The morning my dashboard tried to jump-scare me
&lt;/h2&gt;

&lt;p&gt;One morning I woke up to warning emails from Supabase and Vercel:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Your project has significantly exceeded its Realtime message quota.”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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%2Fkbyi1f5mlff39pkvjy8i.jpg" 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%2Fkbyi1f5mlff39pkvjy8i.jpg" alt=" " width="788" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I opened the dashboard and had to re-check that I was looking at the right project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In just a 10-day span&lt;/strong&gt;, Supabase Realtime (Broadcast + Presence) had processed roughly &lt;strong&gt;50,000,000 messages&lt;/strong&gt; (the bill line item showed &lt;strong&gt;54,557,731&lt;/strong&gt; Realtime Messages).&lt;br&gt;&lt;br&gt;
That wasn’t “a little over.” It was &lt;strong&gt;1000%+ over&lt;/strong&gt; the included quota.&lt;/p&gt;

&lt;p&gt;For context: I run &lt;strong&gt;&lt;a href="https://impostergame.net" rel="noopener noreferrer"&gt;Imposter Game&lt;/a&gt;&lt;/strong&gt; — a browser-based party game (think “Liar Game” / social deduction) that works with &lt;strong&gt;3 to 99 players&lt;/strong&gt;. No installs, no logins — just open a URL and play.&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%2Fkyqb4ijms34d1dsx33vq.jpg" 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%2Fkyqb4ijms34d1dsx33vq.jpg" alt=" " width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;User growth is great… until your side project starts throwing punches at your wallet.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. Technical post-mortem: two failures back-to-back
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: Next.js 15 (App Router)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: Supabase (Postgres)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime&lt;/strong&gt;: Supabase Realtime (Broadcast + Presence)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt;: Upstash Redis (Vercel KV)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Failure 1: Broadcast everything (Supabase Realtime)
&lt;/h3&gt;

&lt;p&gt;My first approach was the classic “real-time multiplayer” instinct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any state change? &lt;strong&gt;Broadcast it immediately.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Timer? &lt;strong&gt;Send updates every second.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Presence? &lt;strong&gt;Track joins/leaves live.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s the core math that bit me: Supabase charges on &lt;strong&gt;egress messages&lt;/strong&gt; — effectively:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1 event × number of subscribers in the room (N)&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So with &lt;strong&gt;N = 50&lt;/strong&gt; players:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every second: &lt;code&gt;1 timer tick × 50 recipients = 50 messages/sec&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;One 15-minute round (900 sec): &lt;code&gt;50 × 900 = 45,000 messages&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add votes, reactions, and Presence traffic…and the number explodes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: &lt;strong&gt;~50M messages in about 10 days&lt;/strong&gt;, quota obliterated.&lt;/p&gt;
&lt;h3&gt;
  
  
  Failure 2: “Fine, I’ll use Redis + polling”
&lt;/h3&gt;

&lt;p&gt;My next thought was also extremely common:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Realtime is expensive. Let’s store state in Redis and have clients poll.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I turned off the broadcast approach and switched to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;State stored in Upstash Redis&lt;/li&gt;
&lt;li&gt;Client polls &lt;code&gt;GET /api/game-state&lt;/code&gt; &lt;strong&gt;once per second&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This looked “cheaper” in my head. It wasn’t.&lt;/p&gt;

&lt;p&gt;If each poll triggers ~3 Redis commands (Room/Round/Player):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;10 concurrent users × 1 poll/sec × 3 commands = 1,800 commands/min&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And Upstash’ &lt;strong&gt;free monthly quota (500k commands)&lt;/strong&gt;?&lt;br&gt;&lt;br&gt;
It evaporated &lt;strong&gt;in less than half a day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I ended up adding a credit card for &lt;strong&gt;Pay As You Go&lt;/strong&gt; just to keep the app alive.&lt;/p&gt;

&lt;p&gt;At that point I had to admit it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Congrats. I just wrote my own DDoS script.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(Also: Vercel and Upstash being in different regions increased RTT and made the whole thing feel even worse.)&lt;/p&gt;


&lt;h2&gt;
  
  
  3. The real realization: I was solving the wrong problem
&lt;/h2&gt;

&lt;p&gt;My initial “solutions” were all server-side optimizations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;batch Redis reads (&lt;code&gt;MGET&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;reduce timer update frequency (1s → 5s)&lt;/li&gt;
&lt;li&gt;compress payloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I paused and pictured the real-world usage.&lt;/p&gt;

&lt;p&gt;Most people play party games… &lt;strong&gt;in the same room&lt;/strong&gt;, &lt;strong&gt;around the same table&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So why were 10 friends at a campsite burning LTE data and battery life, constantly syncing with a server across the planet?&lt;/p&gt;

&lt;p&gt;The problem wasn’t “how do I scale my server cheaper?”&lt;/p&gt;

&lt;p&gt;It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“How do I remove the server from the default experience?”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  4. The pivot: Local‑First, client‑only (“no server” mode)
&lt;/h2&gt;

&lt;p&gt;I made a bold call:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For in-person play, don’t use the network at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not “serverless.” Not “edge.”&lt;br&gt;&lt;br&gt;
Just &lt;strong&gt;0 API calls&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  New architecture: client-only pass-and-play
&lt;/h3&gt;

&lt;p&gt;One phone acts as the host. Players pass the device around to confirm roles (“pass and play”), then play together locally.&lt;/p&gt;

&lt;p&gt;The Local Mode component is a &lt;code&gt;use client&lt;/code&gt; Next.js client component, but internally it behaves like a little state machine.&lt;/p&gt;
&lt;h3&gt;
  
  
  Local timer (no drift, no server)
&lt;/h3&gt;

&lt;p&gt;Instead of server &lt;code&gt;setInterval&lt;/code&gt;, I use &lt;code&gt;requestAnimationFrame&lt;/code&gt; + &lt;code&gt;Date.now()&lt;/code&gt; to compute time-left deterministically.&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;// useGameTimer.ts (simplified)&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;startTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;animationFrameId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&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;newTimeLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nf"&gt;setTimeLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTimeLeft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTimeLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;animationFrameId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nx"&gt;animationFrameId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;cancelAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animationFrameId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Yes, background tabs have constraints — but this is optimized for in-person local play, where the app stays in the foreground.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  State transitions happen in memory
&lt;/h3&gt;

&lt;p&gt;Role assignment (3–99 players), voting, win conditions — everything runs in browser memory.&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;nextPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setGame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;voting&lt;/span&gt;&lt;span class="dl"&gt;"&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateWinner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// local compute&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No network round trip means phase transitions feel instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  INP optimization for 99 players
&lt;/h3&gt;

&lt;p&gt;Rendering and updating a 99-player list can get janky fast.&lt;/p&gt;

&lt;p&gt;React 18’s &lt;code&gt;useTransition&lt;/code&gt; helped keep heavy updates non-blocking:&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;addPlayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setGame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;newPlayers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createNewPlayer&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;balanceRoles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newPlayers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Security note (because someone will ask)
&lt;/h3&gt;

&lt;p&gt;Online mode still requires server-side validation.&lt;/p&gt;

&lt;p&gt;But in Local Mode, the person holding the phone is effectively authenticated by physics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your friends’ eyeballs are the anti-cheat.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Result: costs down, UX up
&lt;/h2&gt;

&lt;p&gt;I restructured the site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local Game (Offline Mode)&lt;/strong&gt; became the main CTA on the homepage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online Game&lt;/strong&gt; stayed as a backup feature (“Remote Mode”).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What changed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realtime messages&lt;/strong&gt;: ~50M → &lt;strong&gt;near zero&lt;/strong&gt; (because most sessions moved to Local Mode)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis usage&lt;/strong&gt;: easily kept within free/low tiers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt;: no more games dying due to disconnects (even in basements, mountains, and spotty areas)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best part: users &lt;em&gt;preferred&lt;/em&gt; the version with no installs, no logins, and no dependency on good internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;As developers, we’re often drawn to “real-time,” “websockets,” and “edge everything.”&lt;/p&gt;

&lt;p&gt;But the best scaling strategy I’ve learned recently is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don’t optimize the server — make the server unnecessary.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sometimes an &lt;code&gt;Array.map&lt;/code&gt; beats a Redis cluster.&lt;/p&gt;




&lt;h3&gt;
  
  
  Try it
&lt;/h3&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%2Ffi4waqgxtezjgj21llr1.jpg" 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%2Ffi4waqgxtezjgj21llr1.jpg" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://impostergame.net" rel="noopener noreferrer"&gt;Play Imposter Game&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
It’s a real demo of a smooth 99-player local party game built with React — no app install required.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>react</category>
      <category>impostergame</category>
    </item>
  </channel>
</rss>
