<?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: Syed Ahmed Ali</title>
    <description>The latest articles on DEV Community by Syed Ahmed Ali (@syedahmedali_dev).</description>
    <link>https://dev.to/syedahmedali_dev</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1468600%2Ff4efcbfa-f20a-4167-a5f8-cd4d87d3427b.jpeg</url>
      <title>DEV Community: Syed Ahmed Ali</title>
      <link>https://dev.to/syedahmedali_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syedahmedali_dev"/>
    <language>en</language>
    <item>
      <title>How I Used Redis to Stop My Football Web App From Hammering the Database</title>
      <dc:creator>Syed Ahmed Ali</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:54:23 +0000</pubDate>
      <link>https://dev.to/syedahmedali_dev/how-i-used-redis-to-stop-my-football-web-app-from-hammering-the-database-hpe</link>
      <guid>https://dev.to/syedahmedali_dev/how-i-used-redis-to-stop-my-football-web-app-from-hammering-the-database-hpe</guid>
      <description>&lt;p&gt;&lt;em&gt;A practical walkthrough of the cache-aside pattern — why it exists, how to implement it, and what breaks if you skip it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When I built Flacron Gamezone, one of the first questions I had to answer wasn't about the UI or the API design. It was simpler and more uncomfortable than that: &lt;strong&gt;what happens when fifty users refresh the live scores page at the same time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The honest answer, without caching, is that all fifty of them hit the database. And then the next fifty do the same. And the one after that. For a football platform where the data changes every few minutes but gets &lt;em&gt;read&lt;/em&gt; thousands of times between those changes, that's a lot of identical queries doing identical work for no reason.&lt;/p&gt;

&lt;p&gt;This is the problem Redis solves. More specifically, it's the problem the cache-aside pattern solves. Here's how I implemented it, what it looks like in production, and why the details matter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Just Cache Everything?
&lt;/h2&gt;

&lt;p&gt;Before getting into implementation, it's worth being honest about the tradeoff you're making when you add a cache.&lt;/p&gt;

&lt;p&gt;Every cached value is a bet: you're betting that serving slightly stale data is better than the cost of hitting the database every time. For most data on most applications, that bet is obviously correct. But there are places where it isn't — user account data, payment state, anything where correctness matters more than speed.&lt;/p&gt;

&lt;p&gt;On Flacron Gamezone, the split was clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live match data&lt;/strong&gt; — read constantly, changes infrequently, perfect for caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User subscription status&lt;/strong&gt; — hits Stripe and the database, should never be stale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication tokens&lt;/strong&gt; — never cached, always verified fresh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting this split right before you write a single line of caching code matters more than any implementation detail.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cache-Aside Pattern
&lt;/h2&gt;

&lt;p&gt;Cache-aside (also called lazy loading) is the most common caching strategy, and for good reason — it's simple, it's predictable, and it fails gracefully.&lt;/p&gt;

&lt;p&gt;The logic on every read request is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the cache first&lt;/li&gt;
&lt;li&gt;If the value is there (cache hit), return it&lt;/li&gt;
&lt;li&gt;If it's not (cache miss), fetch from the database, store it in the cache, then return it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The cache only gets populated with data that's actually been requested. You're not pre-loading anything speculatively.&lt;/p&gt;

&lt;p&gt;Here's what this looks like in code, in the context of the match service:&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;// services/matchService.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../lib/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;matchRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../repositories/matchRepository&lt;/span&gt;&lt;span class="dl"&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;CACHE_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 minute for live match data&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLiveMatches&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Match&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;matches:live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: Check the cache&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&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;redis&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="nx"&gt;cacheKey&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;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Cache miss — fetch from database&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&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;matchRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findLive&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3: Store in cache with TTL, then return&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CACHE_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;matches&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;Clean, readable, and the logic is entirely self-contained in the service layer. The controller doesn't know caching exists. The repository doesn't know either. That separation matters when you need to change the caching strategy later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up the Redis Client
&lt;/h2&gt;

&lt;p&gt;I'm using &lt;a href="https://upstash.com" rel="noopener noreferrer"&gt;Upstash&lt;/a&gt; for Redis in production — it's serverless, has a generous free tier, and works well with Vercel deployments. Locally, I run Redis via Docker.&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;// lib/redis.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@upstash/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UPSTASH_REDIS_REST_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UPSTASH_REDIS_REST_TOKEN&lt;/span&gt;&lt;span class="o"&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;One thing worth noting: Upstash uses an HTTP-based client (&lt;code&gt;@upstash/redis&lt;/code&gt;), not the traditional &lt;code&gt;ioredis&lt;/code&gt; TCP connection. For serverless environments, this is actually the correct choice — TCP connections don't survive the stateless nature of serverless functions well. If you're running a persistent Express server (not serverless), &lt;code&gt;ioredis&lt;/code&gt; is the standard option.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cache Invalidation: The Hard Part
&lt;/h2&gt;

&lt;p&gt;There's a famous saying in computer science: &lt;em&gt;"There are only two hard things: cache invalidation and naming things."&lt;/em&gt; It's a cliché because it's true.&lt;/p&gt;

&lt;p&gt;Cache-aside with a TTL handles the simple case automatically — the cache entry expires after 60 seconds, and the next request repopulates it. For live match scores, this is acceptable. A one-minute lag in a score update isn't a product failure.&lt;/p&gt;

&lt;p&gt;But for data that changes on user action — like when an admin updates match details — waiting for TTL expiry isn't good enough. You need active invalidation.&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;// services/matchService.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Match&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Match&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;updated&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;matchRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Invalidate related cache keys immediately after a write&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;matches:live&lt;/span&gt;&lt;span class="dl"&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`match:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;updated&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;The rule I follow: &lt;strong&gt;every write operation that could affect a cached read should also delete those cache keys.&lt;/strong&gt; The next read will repopulate the cache with fresh data automatically. You're not managing the cache value directly — you're just telling it to forget what it knows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Cache Failures Gracefully
&lt;/h2&gt;

&lt;p&gt;Here's something tutorials almost never cover: what happens when Redis goes down?&lt;/p&gt;

&lt;p&gt;If your application throws an unhandled error every time the cache is unavailable, you've traded a performance problem for an availability problem. The whole point of a cache is to be an optimization, not a dependency.&lt;/p&gt;

&lt;p&gt;The fix is straightforward — wrap cache operations in try/catch and fall through to the database on failure:&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLiveMatches&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Match&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;matches:live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&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;cached&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;redis&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="nx"&gt;cacheKey&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;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Redis is unavailable — log it, but don't crash&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache read failed, falling back to DB:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&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;matches&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;matchRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findLive&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CACHE_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Cache write failed — still return the data&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache write failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;matches&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;Your app is slower when Redis is down. It is not broken. That's the correct behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Numbers Actually Look Like
&lt;/h2&gt;

&lt;p&gt;Before caching, every request to the live matches endpoint was a database query. On a day with multiple live matches and active users, that's potentially hundreds of queries per minute for data that changes once every sixty seconds.&lt;/p&gt;

&lt;p&gt;After caching, the database sees &lt;strong&gt;one query per minute&lt;/strong&gt; for live match data regardless of traffic. Everything else is served from memory in under a millisecond. That's not a marginal improvement — it's a fundamentally different load profile.&lt;/p&gt;

&lt;p&gt;The TTL is the lever you control. Lower it and your data is fresher but your cache hit rate drops. Raise it and your hit rate improves but stale data becomes more of a risk. For Flacron Gamezone, 60 seconds was the right balance. For a financial dashboard, you'd probably drop it to 5 or 10. For a product catalog that rarely changes, you might go hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;One thing I got wrong early on was using overly broad cache keys. I had a single &lt;code&gt;matches:all&lt;/code&gt; key that cached the entire match list. The moment I added pagination, that key became useless — the cached value was always the wrong page.&lt;/p&gt;

&lt;p&gt;The right approach is to build cache keys that reflect exactly what was queried:&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`matches:status:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:page:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:limit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More keys means more cache entries to manage and invalidate, but it also means your cache is actually useful across different query shapes. The tradeoff is worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The cache-aside pattern is three steps: check the cache, miss to the database, write back. The important decisions are around which data to cache, what TTL makes sense for your use case, how to invalidate on writes, and how to handle cache failures without taking down your application.&lt;/p&gt;

&lt;p&gt;None of this requires a complex setup. A single Redis client, one utility function, and consistent discipline about where caching logic lives in your service layer is enough to meaningfully change how your backend performs under load.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Flacron Gamezone is live at &lt;a href="https://flacrongamezone.com" rel="noopener noreferrer"&gt;flacrongamezone.com&lt;/a&gt;. If you're building a backend and want a developer who thinks about these problems before they become production incidents, reach me at &lt;a href="https://syedahmedali.com" rel="noopener noreferrer"&gt;syedahmedali.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>performance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Next.js Data Cache Error That Broke My Production App (And How I Fixed It)</title>
      <dc:creator>Syed Ahmed Ali</dc:creator>
      <pubDate>Sun, 14 Jun 2026 12:27:49 +0000</pubDate>
      <link>https://dev.to/syedahmedali_dev/the-nextjs-data-cache-error-that-broke-my-production-app-and-how-i-fixed-it-4o9</link>
      <guid>https://dev.to/syedahmedali_dev/the-nextjs-data-cache-error-that-broke-my-production-app-and-how-i-fixed-it-4o9</guid>
      <description>&lt;h1&gt;
  
  
  The Next.js Data Cache Error That Broke My Production App (And How I Fixed It)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;A real bug from building Flacron Gamezone — a live football platform built on Next.js 14 App Router.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;There's a particular kind of bug that only shows up in production. Not because your environment variables are wrong or your build config is off — but because the framework is doing exactly what it was designed to do, and that design doesn't match what you assumed.&lt;/p&gt;

&lt;p&gt;This is the story of one of those bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Flacron Gamezone is a live football match discovery platform. The matches page lists upcoming and live games, paginated, with filters. Simple enough on the surface.&lt;/p&gt;

&lt;p&gt;The data fetching was originally done server-side. A server component would call a helper function — &lt;code&gt;apiGet()&lt;/code&gt; — which internally called &lt;code&gt;fetch()&lt;/code&gt; and returned the parsed JSON. The page rendered on the server, data included, and everything worked locally.&lt;/p&gt;

&lt;p&gt;Then we deployed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Error
&lt;/h2&gt;

&lt;p&gt;In production, the matches page started throwing a cryptic error after the first load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Dynamic server usage: Page couldn't be rendered statically because it used `headers`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or sometimes, depending on the Next.js version and the exact request path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Cannot read properties of undefined (reading 'data')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What made it worse: it was &lt;em&gt;intermittent&lt;/em&gt;. Refresh the page five times and it worked four of them. That kind of flakiness is the hardest to debug because it makes you doubt whether the bug is even real.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Was Actually Happening
&lt;/h2&gt;

&lt;p&gt;Next.js App Router has a built-in data cache. When you call &lt;code&gt;fetch()&lt;/code&gt; inside a server component, Next.js caches that response by default — across requests, not just within a single render.&lt;/p&gt;

&lt;p&gt;The problem: our &lt;code&gt;apiGet()&lt;/code&gt; helper wasn't setting &lt;code&gt;cache: "no-store"&lt;/code&gt;. So Next.js was caching the first response and serving that cached data on subsequent requests — &lt;em&gt;even when the underlying data had changed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;For a football platform showing live scores, stale cached data isn't just a bug. It's the whole product being wrong.&lt;/p&gt;

&lt;p&gt;But there was a second problem layered on top. The server component was trying to read paginated data based on URL search params (&lt;code&gt;?page=2&lt;/code&gt;, &lt;code&gt;?status=live&lt;/code&gt;). In some rendering paths, Next.js couldn't statically analyze those dynamic dependencies cleanly — especially when combined with the caching behavior. That's where the &lt;code&gt;Dynamic server usage&lt;/code&gt; error was coming from.&lt;/p&gt;

&lt;p&gt;The two issues were compounding each other.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Tried First (That Didn't Work)
&lt;/h2&gt;

&lt;p&gt;The first instinct was to just add &lt;code&gt;cache: "no-store"&lt;/code&gt; to the fetch calls inside &lt;code&gt;apiGet()&lt;/code&gt;.&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixed the stale data problem. But the intermittent render error on dynamic params persisted. The server component was still trying to read &lt;code&gt;searchParams&lt;/code&gt; in a way that conflicted with how App Router handles static vs dynamic rendering.&lt;/p&gt;

&lt;p&gt;I also tried wrapping the page in &lt;code&gt;export const dynamic = "force-dynamic"&lt;/code&gt;. That made the error go away — but it also meant the entire page was fully server-rendered on every request with no caching benefit at all. For a page with pagination and filters, that felt like giving up rather than solving the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Actual Fix: Move Pagination to the Client
&lt;/h2&gt;

&lt;p&gt;The real solution was rethinking &lt;em&gt;where&lt;/em&gt; the data fetching happened.&lt;/p&gt;

&lt;p&gt;The matches listing didn't need to be a server-rendered data fetch at all. The page shell — the layout, the filters UI, the heading — could render on the server. But the paginated match data should be fetched client-side, on demand, triggered by the user's current page and filter state.&lt;/p&gt;

&lt;p&gt;Here's what the refactored approach looked like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (server component fetching paginated data):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/matches/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MatchesPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;searchParams&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="nl"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;apiGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/matches?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;status=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MatchList&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (client component fetching on state change):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/matches/page.tsx — server component, renders the shell&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MatchesPage&lt;/span&gt;&lt;span class="p"&gt;()&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MatchesClient&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// components/MatchesClient.tsx — "use client"&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MatchesClient&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMatches&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&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;fetchMatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/matches?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;status=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;setMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;fetchMatches&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;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&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="c1"&gt;// render matches, pagination controls, filter buttons&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;The key changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server component becomes a thin shell that renders the client component. No data fetching, no dynamic param reading.&lt;/li&gt;
&lt;li&gt;The client component owns the pagination and filter state via &lt;code&gt;useState&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useEffect&lt;/code&gt; re-fetches whenever &lt;code&gt;page&lt;/code&gt; or &lt;code&gt;status&lt;/code&gt; changes — cleanly, predictably, with no caching issues because we control the fetch directly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache: "no-store"&lt;/code&gt; is explicit on every fetch. No surprises.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why This Is Actually the Right Model for This Feature
&lt;/h2&gt;

&lt;p&gt;It's tempting to feel like moving data fetching to the client is "giving up" on the server-side rendering benefits of App Router. But that framing misses the point.&lt;/p&gt;

&lt;p&gt;Server components are excellent for data that's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Known at request time&lt;/li&gt;
&lt;li&gt;Not dependent on user interaction state&lt;/li&gt;
&lt;li&gt;Appropriate to cache (even briefly)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paginated, filterable, user-driven listings are none of those things. The user's current page and filter selection &lt;em&gt;is&lt;/em&gt; the state. It changes constantly. Trying to drive that from the server via URL params creates exactly the kind of rendering ambiguity that caused this bug.&lt;/p&gt;

&lt;p&gt;Client-side fetching for interactive, stateful UI isn't a fallback. It's the correct tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Lesson
&lt;/h2&gt;

&lt;p&gt;Next.js App Router is powerful, but it has opinions. It will cache your fetch calls unless you tell it not to. It will try to statically optimize your pages unless you tell it they're dynamic. When those defaults conflict with what your feature actually needs, you don't fight the framework — you understand what it's trying to do and work with the model it's designed around.&lt;/p&gt;

&lt;p&gt;In this case, the model is clear: server components for stable, cacheable data. Client components for interactive, user-driven state. The bug was the framework telling me I'd put the logic in the wrong place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Root Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stale data in production&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetch()&lt;/code&gt; cached by default in App Router&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;cache: "no-store"&lt;/code&gt; explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic server usage error&lt;/td&gt;
&lt;td&gt;Server component reading search params with conflicting cache behavior&lt;/td&gt;
&lt;td&gt;Move paginated fetching to a &lt;code&gt;"use client"&lt;/code&gt; component with &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intermittent failures&lt;/td&gt;
&lt;td&gt;Both issues compounding each other&lt;/td&gt;
&lt;td&gt;Refactor to client-side paginated fetching entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;If you're building on Next.js 14 App Router and hitting intermittent cache-related errors on pages with dynamic params — check where your fetch calls live and who owns the state. The answer is usually in the split between server and client, not in your network or your API.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ahmed Ali is a Full-Stack Developer based in Pakistan, building production-ready web apps with Next.js, Node.js, PostgreSQL, and TypeScript. Flacron Gamezone is live at &lt;a href="https://flacrongamezone.com" rel="noopener noreferrer"&gt;flacrongamezone.com&lt;/a&gt;. Reach me at &lt;a href="https://syedahmedali.com" rel="noopener noreferrer"&gt;syedahmedali.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Built a Live Football Platform That Doesn't Fall Apart Under Load</title>
      <dc:creator>Syed Ahmed Ali</dc:creator>
      <pubDate>Sun, 31 May 2026 09:33:11 +0000</pubDate>
      <link>https://dev.to/syedahmedali_dev/how-i-built-a-live-football-platform-that-doesnt-fall-apart-under-load-439e</link>
      <guid>https://dev.to/syedahmedali_dev/how-i-built-a-live-football-platform-that-doesnt-fall-apart-under-load-439e</guid>
      <description>&lt;p&gt;&lt;em&gt;A walkthrough of the architecture decisions behind Flacron Gamezone a production full-stack app built with Next.js, Express, PostgreSQL, and Redis.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a client approached me to build a live football match discovery platform, the requirements sounded straightforward on the surface: show live scores, let users subscribe, handle authentication. But the moment you start thinking about &lt;em&gt;how&lt;/em&gt; those pieces connect in production, straightforward gets complicated fast.&lt;/p&gt;

&lt;p&gt;This is the story of how I designed the backend for &lt;a href="https://flacrongamezone.com" rel="noopener noreferrer"&gt;Flacron Gamezone&lt;/a&gt; — what decisions I made, why I made them, and what broke along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Problem With "Just Building It"&lt;/li&gt;
&lt;li&gt;The Architecture: Four Distinct Layers&lt;/li&gt;
&lt;li&gt;Why This Matters to a Client&lt;/li&gt;
&lt;li&gt;The Bug That Taught Me Something Real&lt;/li&gt;
&lt;li&gt;The Full Stack at a Glance&lt;/li&gt;
&lt;li&gt;What I'd Do Differently&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem With "Just Building It" &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The easiest version of this app is a single Express file: one route handler that queries the database, formats the data, and sends a response. I've seen this pattern in tutorials everywhere. It works for demos. It falls apart in production.&lt;/p&gt;

&lt;p&gt;The problems are predictable: you can't test business logic without hitting the database, a change in one feature quietly breaks another, and the moment a second developer joins the codebase, nobody knows where anything lives.&lt;/p&gt;

&lt;p&gt;I wanted to build something I could actually be proud to show an employer or a client. That meant committing to a proper layered architecture from day one, even on a project this size.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Four Distinct Layers &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The entire Express backend is organized into four layers. Each layer has one job and talks only to the layer directly below it.&lt;/p&gt;

&lt;p&gt;Route → Controller → Service → Repository&lt;/p&gt;

&lt;p&gt;Here's what each one actually does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routes&lt;/strong&gt; are just maps. They declare that &lt;code&gt;POST /api/v1/subscriptions&lt;/code&gt; exists, attach the auth middleware, and hand off to the controller. No logic lives here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controllers&lt;/strong&gt; handle the HTTP boundary. They extract data from &lt;code&gt;req.body&lt;/code&gt; or &lt;code&gt;req.params&lt;/code&gt;, call the appropriate service method, and send back a formatted response. They don't know anything about databases. They don't contain business rules. If a request comes in malformed, the controller catches it and returns a 400. That's the full extent of its responsibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Services&lt;/strong&gt; are where the application logic lives. A &lt;code&gt;SubscriptionService&lt;/code&gt; knows that before creating a subscription, it needs to verify the user doesn't already have one, call Stripe to create a customer, and only then persist the record. It coordinates between multiple repositories if needed. It throws typed errors that the controller can catch and translate into HTTP responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repositories&lt;/strong&gt; are the only layer that touches the database. A &lt;code&gt;UserRepository&lt;/code&gt; has methods like &lt;code&gt;findById&lt;/code&gt;, &lt;code&gt;findByEmail&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;. It uses Prisma under the hood. Nothing outside this layer writes a SQL query or touches a Prisma client directly.&lt;/p&gt;

&lt;p&gt;The result is that when the client asked me to change how subscriptions were priced mid-project, I changed two methods in &lt;code&gt;SubscriptionService&lt;/code&gt; and one in &lt;code&gt;SubscriptionRepository&lt;/code&gt;. The routes, controllers, and every other service were completely untouched.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters to a Client &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;If you're a client reading this: layered architecture means your project doesn't become unmaintainable the moment the original developer moves on. Any competent backend developer can read this codebase and understand where to make a change within minutes, not hours.&lt;/p&gt;

&lt;p&gt;If you're an employer reading this: this is the pattern used in production systems at scale. The reason it's taught in enterprise codebases isn't bureaucracy — it's because the alternative is a system where nobody can safely change anything without breaking something else.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug That Taught Me Something Real &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Midway through development, Prisma started throwing connection timeout errors. Not consistently — just intermittently, and always on the first query after a cold start.&lt;/p&gt;

&lt;p&gt;I spent an embarrassing amount of time checking my connection pool configuration, my &lt;code&gt;.env&lt;/code&gt; file, my database credentials. Everything looked correct.&lt;/p&gt;

&lt;p&gt;The actual problem: on Windows, &lt;code&gt;localhost&lt;/code&gt; resolves to the IPv6 address &lt;code&gt;::1&lt;/code&gt; before it tries IPv4 &lt;code&gt;127.0.0.1&lt;/code&gt;. PostgreSQL, by default, listens on IPv4. Prisma was trying to connect to the IPv6 address, the connection was being refused, and the error message gave me no indication that address resolution was even involved.&lt;/p&gt;

&lt;p&gt;The fix was a single character change in the database URL — replacing &lt;code&gt;localhost&lt;/code&gt; with &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before&lt;br&gt;
DATABASE_URL="postgresql://user:pass@localhost:5432/flacron"&lt;br&gt;
After&lt;br&gt;
DATABASE_URL="postgresql://user:&lt;a href="mailto:pass@127.0.0.1"&gt;pass@127.0.0.1&lt;/a&gt;:5432/flacron"&lt;/p&gt;

&lt;p&gt;I'm documenting this here because I found almost nothing about it when I was searching. If you're on Windows and Prisma is timing out on cold start, try this before you spend three hours reading connection pool documentation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Stack at a Glance &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js 15 with App Router, deployed on Vercel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Express.js with the four-layer architecture described above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL with Prisma ORM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache:&lt;/strong&gt; Redis for live match data (cache-aside pattern, short TTL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; Stripe subscriptions with webhook verification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; JWT access/refresh token pattern with role-based guards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each piece was chosen deliberately. Redis sits in front of the live match queries specifically because that data is read far more often than it's written, and hitting the database on every poll request doesn't scale. Stripe handles payments because rolling your own payment processing is never the right call. PostgreSQL over MongoDB because this data is relational — users, subscriptions, and matches have hard dependencies between them that a document database makes awkward to enforce.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently &lt;a&gt;&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;One thing I underestimated was how much time Stripe webhook handling would take to get right. Verifying the webhook signature, handling idempotency, making the handler resilient to duplicate events — each of those is its own small problem. I'd budget more time for that in future projects.&lt;/p&gt;

&lt;p&gt;I'd also set up structured logging with Pino earlier. Console logs are fine locally. The first time you're debugging a production issue and you have no searchable, structured log trail, you feel it immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Live Project
&lt;/h2&gt;

&lt;p&gt;Flacron Gamezone is live at &lt;a href="https://flacrongamezone.com" rel="noopener noreferrer"&gt;flacrongamezone.com&lt;/a&gt;. The source code for the local development version is on &lt;a href="https://github.com/HafizSyedAhmedAli/Flacron-Gamezone-Local" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something and want a developer who thinks about architecture before writing the first line of code, reach me at &lt;a href="https://syedahmedali.com" rel="noopener noreferrer"&gt;syedahmedali.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ahmed Ali is a Full-Stack Developer based in Pakistan, building production-ready web apps and AI-powered SaaS products. Next.js · Node.js · PostgreSQL · Redis · TypeScript.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
