<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>blog - Jeffrey Jose</title>
    <description>learning things, building things, breaking things, writing about it. technical projects, personal struggles, life stuff, random observations - just documenting whatever I'm working through.</description>
    <link>https://jeffreyjose07.is-a.dev/blog</link>
    <atom:link href="https://jeffreyjose07.is-a.dev/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <language>en-us</language>
    <lastBuildDate>Wed, 05 Aug 2026 00:00:00 GMT</lastBuildDate>
    <generator>Custom Node.js Blog Builder</generator>
    <copyright>Copyright 2026 Jeffrey Jose</copyright>
    <item>
      <title><![CDATA[Four Reasonable Decisions and a Dead Feed]]></title>
      <description><![CDATA[Every page on this blog advertised an RSS feed that returned 404. No single line of code was wrong — four independently sensible decisions composed into a file that existed only inside a CI runner. Plus a cross-domain sitemap, a verification file an SPA can fake, and what Hugo, Astro and Eleventy do differently.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/four-reasonable-decisions-and-a-dead-feed</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/four-reasonable-decisions-and-a-dead-feed</guid>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[meta]]></category>
      <category><![CDATA[blog]]></category>
      <category><![CDATA[rss]]></category>
      <category><![CDATA[seo]]></category>
      <category><![CDATA[static-site-generator]]></category>
      <category><![CDATA[build-systems]]></category>
      <category><![CDATA[ci]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/four-reasonable-decisions-and-a-dead-feed.png" />
      <content:encoded><![CDATA[<p>Working log for this blog. <strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/documentation-that-outlived-its-code">Documentation That Outlived Its Code</a>.</p>
<p>Last episode I audited every Markdown file in this repo against the code and found that all seven described a site that no longer existed. The obvious follow-up was to point the same suspicion at the build system: not &quot;does it run?&quot; — it runs fine, 36 posts in about four seconds — but &quot;does what it claims to produce actually reach the server?&quot;</p>
<p>The first thing I checked was the RSS feed.</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sL</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> /dev/null</span><span style="color:#D19A66"> -w</span><span style="color:#98C379"> "%{http_code}\n"</span><span style="color:#98C379"> https://jeffreyjose07.is-a.dev/blog/feed.xml</span></span>
<span class="line"><span style="color:#61AFEF">404</span></span></code></pre></div>

<p>Every page on this blog has advertised that URL for months.</p>
<hr>
<h2>Nobody wrote a bug</h2>
<p>The interesting part is that no line of code here is wrong. Four decisions, each defensible in isolation, composed into a file that only ever existed inside a GitHub Actions runner.</p>
<p><strong>Decision one.</strong> The feed generator only runs under CI:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> (</span><span style="color:#E5C07B">process</span><span style="color:#ABB2BF">.</span><span style="color:#E5C07B">env</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">GITHUB_ACTIONS</span><span style="color:#56B6C2"> ===</span><span style="color:#98C379"> 'true'</span><span style="color:#56B6C2"> ||</span><span style="color:#E5C07B"> process</span><span style="color:#ABB2BF">.</span><span style="color:#E5C07B">env</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">CI</span><span style="color:#56B6C2"> ===</span><span style="color:#98C379"> 'true'</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#61AFEF">    generateRSSfeed</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">posts</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#ABB2BF">} </span><span style="color:#C678DD">else</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E5C07B">    console</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">log</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">'ℹ️ Skipping RSS feed generation (not running in CI).'</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>Reasonable-sounding: the feed embeds absolute production URLs, so why generate it on a laptop?</p>
<p><strong>Decision two.</strong> <code>.gitignore</code> excludes it, filed under a stray <code># Vercel</code> heading:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span># Vercel</span></span>
<span class="line"><span>.vercel</span></span>
<span class="line"><span>public/blog/feed.xml</span></span></code></pre></div>

<p>Also reasonable: it&#39;s a build artifact, and build artifacts don&#39;t belong in git.</p>
<p><strong>Decision three.</strong> CI commits its generated output by directory:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">git add public/blog/ public/assets/thumbnails/</span></span></code></pre></div>

<p>And <code>git add</code> on a directory silently skips ignored files. No error. No warning. The feed is generated on the runner, staged by nobody, and evaporates when the job ends.</p>
<p><strong>Decision four.</strong> The blog build is conditional, to avoid rebuilding 36 posts when only React code changed:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">- </span><span style="color:#E06C75">name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Build blog</span></span>
<span class="line"><span style="color:#E06C75">  if</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">steps.blog-changes.outputs.blog_changed == 'true'</span></span></code></pre></div>

<p>Put those together and the feed exists in exactly one situation: a deploy whose commit touched <code>blog/</code>. Every other deploy publishes a <code>dist/</code> with no feed in it. My most recent commit before this audit was <code>fix(RecentWriting): show four posts</code> — <code>src/</code> only. So the live site lost its feed, and the three places advertising it kept advertising it:</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">link</span><span style="color:#D19A66"> rel</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"alternate"</span><span style="color:#D19A66"> type</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"application/rss+xml"</span><span style="color:#D19A66"> title</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"RSS Feed"</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"/blog/feed.xml"</span><span style="color:#ABB2BF"> /></span></span></code></pre></div>

<p>Plus a visible footer link, plus a <code>sitemap.xml</code> entry submitting it to search engines with <code>changefreq: daily</code> — asking Google to crawl a 404 every day.</p>
<p>The control experiment was sitting right next to it. <code>sitemap.xml</code> is generated by the same script into the same directory and returns 200. The only difference: it isn&#39;t gitignored.</p>
<p><strong>The generalisable rule:</strong> a file that is both <em>conditionally generated</em> and <em>excluded from version control</em> has no durable home. It exists only in the window between generation and deploy, and any path that skips generation ships a site without it.</p>
<h2>The sitemap was lying too</h2>
<p>Second thing I checked: rebuild with zero content changes and see what <code>git status</code> says.</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span> M public/blog/search.json</span></span>
<span class="line"><span> M public/blog/sitemap.xml</span></span></code></pre></div>

<p>Sixty lines of diff from a no-op build:</p>
<div class="code-block-wrap" data-lang="diff"><span class="code-lang" aria-hidden="true">diff</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">     &#x3C;loc>https://jeffreyjose07.is-a.dev/&#x3C;/loc></span></span>
<span class="line"><span style="color:#E06C75">-    &#x3C;lastmod>2026-08-01&#x3C;/lastmod></span></span>
<span class="line"><span style="color:#98C379">+    &#x3C;lastmod>2026-08-05&#x3C;/lastmod></span></span></code></pre></div>

<p>The static pages were stamped with <code>new Date()</code> — the <em>build</em> date, not the content date. So every build told crawlers the homepage had changed when it hadn&#39;t.</p>
<p>Google&#39;s own guidance is that it uses <code>lastmod</code> only when the value is &quot;consistently and verifiably accurate.&quot; Stamping today&#39;s date on everything is precisely how you teach a crawler that your <code>lastmod</code> is noise, at which point it stops reading it — and you&#39;ve lost a real signal for the one time a page genuinely changes.</p>
<p>The same file also carried <code>&lt;priority&gt;</code> and <code>&lt;changefreq&gt;</code> on every URL. Google ignores both. They were so widely abused — everyone setting <code>priority=1.0</code> on everything — that the fields became meaningless. Two-thirds of that file was decoration, and the remaining third was actively counterproductive.</p>
<p><strong>Hugo solved this years ago</strong> with <a href="https://gohugo.io/methods/page/gitinfo/"><code>enableGitInfo</code></a>: <code>lastmod</code> comes from the last git commit that touched the file. It&#39;s honest by construction, because the thing you&#39;re claiming (this content changed on this date) is exactly what git recorded. The one gotcha is that CI needs a deep clone, or every file reports the clone commit. This repo already checks out with <code>fetch-depth: 0</code>.</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> out</span><span style="color:#56B6C2"> =</span><span style="color:#61AFEF"> execFileSync</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">'git'</span><span style="color:#ABB2BF">, [</span><span style="color:#98C379">'log'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'-1'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'--format=%cs'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'--'</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">relPath</span><span style="color:#ABB2BF">], {</span></span>
<span class="line"><span style="color:#E06C75">    cwd</span><span style="color:#ABB2BF">: </span><span style="color:#E5C07B">REPO_ROOT</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">encoding</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">'utf8'</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">stdio</span><span style="color:#ABB2BF">: [</span><span style="color:#98C379">'ignore'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'pipe'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'ignore'</span><span style="color:#ABB2BF">],</span></span>
<span class="line"><span style="color:#ABB2BF">});</span></span></code></pre></div>

<h2>Local builds and CI builds disagreed</h2>
<p>The <code>search.json</code> line in that diff was a different bug wearing the same clothes.</p>
<p>Three generators each sorted the <em>shared</em> posts array in place:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> sortedPosts</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> posts</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">sort</span><span style="color:#ABB2BF">((</span><span style="color:#E06C75;font-style:italic">a</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">b</span><span style="color:#ABB2BF">) </span><span style="color:#C678DD">=></span><span style="color:#E5C07B"> b</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">episodeNumber</span><span style="color:#56B6C2"> -</span><span style="color:#E5C07B"> a</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">episodeNumber</span><span style="color:#ABB2BF">);   </span><span style="color:#7F848E;font-style:italic">// index</span></span>
<span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> sortedPosts</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> posts</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">sort</span><span style="color:#ABB2BF">((</span><span style="color:#E06C75;font-style:italic">a</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">b</span><span style="color:#ABB2BF">) </span><span style="color:#C678DD">=></span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> Date</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">b</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">date</span><span style="color:#ABB2BF">) </span><span style="color:#56B6C2">-</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> Date</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">a</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">date</span><span style="color:#ABB2BF">)); </span><span style="color:#7F848E;font-style:italic">// feed</span></span>
<span class="line"><span style="color:#E5C07B">postsData</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">sort</span><span style="color:#ABB2BF">((</span><span style="color:#E06C75;font-style:italic">a</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">b</span><span style="color:#ABB2BF">) </span><span style="color:#C678DD">=></span><span style="color:#61AFEF"> parseInt</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">b</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">episode</span><span style="color:#ABB2BF">) </span><span style="color:#56B6C2">-</span><span style="color:#61AFEF"> parseInt</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">a</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">episode</span><span style="color:#ABB2BF">));           </span><span style="color:#7F848E;font-style:italic">// json</span></span></code></pre></div>

<p><code>Array.prototype.sort</code> mutates. So each generator inherited whatever order the previous one left behind — and because the feed generator <em>only ran in CI</em>, the archive and sitemap came out in one order on my laptop and a different order on the runner.</p>
<p>That&#39;s the worst kind of build bug. It doesn&#39;t break anything visible; it just means <code>git status</code> shows phantom diffs you can&#39;t reproduce, until you stop trusting <code>git status</code>.</p>
<p>The fix is four characters: <code>[...posts].sort(...)</code>.</p>
<h2>What the mature generators do</h2>
<p>Since I was already rewriting this, I read how projects with more users than me handle the same problems.</p>
<p><strong>Astro&#39;s <a href="https://docs.astro.build/en/guides/content-collections/">content collections</a></strong> validate frontmatter against a Zod schema at build time and fail loudly rather than deploying broken content. My build validated nothing. A missing <code>description</code> would have sailed through and rendered <code>undefined</code> into a meta tag.</p>
<p>I don&#39;t want a Zod dependency in a build script this small, but the <em>idea</em> is free:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">for</span><span style="color:#ABB2BF"> (</span><span style="color:#C678DD">const</span><span style="color:#E5C07B"> field</span><span style="color:#C678DD"> of</span><span style="color:#ABB2BF"> [</span><span style="color:#98C379">'title'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'date'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'description'</span><span style="color:#ABB2BF">]) {</span></span>
<span class="line"><span style="color:#C678DD">    if</span><span style="color:#ABB2BF"> (</span><span style="color:#56B6C2">!</span><span style="color:#E06C75">frontmatter</span><span style="color:#ABB2BF">[</span><span style="color:#E06C75">field</span><span style="color:#ABB2BF">] </span><span style="color:#56B6C2">||</span><span style="color:#61AFEF"> String</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">frontmatter</span><span style="color:#ABB2BF">[</span><span style="color:#E06C75">field</span><span style="color:#ABB2BF">]).</span><span style="color:#61AFEF">trim</span><span style="color:#ABB2BF">() </span><span style="color:#56B6C2">===</span><span style="color:#98C379"> ''</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#E5C07B">        errors</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">push</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">`</span><span style="color:#C678DD">${</span><span style="color:#E06C75">where</span><span style="color:#C678DD">}</span><span style="color:#98C379">: missing required frontmatter "</span><span style="color:#C678DD">${</span><span style="color:#E06C75">field</span><span style="color:#C678DD">}</span><span style="color:#98C379">"`</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>The check that actually earns its keep is one no general-purpose generator could know about. Episode numbers here come from <em>array position</em>:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> posts</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> included</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">map</span><span style="color:#ABB2BF">(({ </span><span style="color:#E06C75;font-style:italic">file</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">frontmatter</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">index</span><span style="color:#ABB2BF"> }) </span><span style="color:#C678DD">=></span><span style="color:#ABB2BF"> ({</span></span>
<span class="line"><span style="color:#E06C75">    episodeNumber</span><span style="color:#ABB2BF">: </span><span style="color:#E06C75">index</span><span style="color:#ABB2BF">,   </span><span style="color:#7F848E;font-style:italic">// ← derived from sort order, not the filename</span></span>
<span class="line"><span style="color:#ABB2BF">    ...</span></span>
<span class="line"><span style="color:#ABB2BF">}));</span></span></code></pre></div>

<p>The <code>012-</code> prefix in the filename is decorative. Delete one post and every later episode silently renumbers — in prose, in URLs, in the &quot;previous episode&quot; links that every post opens with. The invariant held across all 36 posts, but nothing enforced it. Now something does:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>blog/posts/013-migrating-from-render-to-neon-postgresql.md: filename prefix 013</span></span>
<span class="line"><span>but sort position 012 — renumbering would change published episode numbers</span></span></code></pre></div>

<p><strong>Eleventy&#39;s <a href="https://www.11ty.dev/docs/config-preprocessors/">preprocessor API</a></strong> drops <code>draft: true</code> content when <code>RUN_MODE === &quot;build&quot;</code> but keeps it visible while you&#39;re writing. I&#39;d been carrying no draft support at all — every <code>.md</code> in <code>blog/posts/</code> shipped the moment it existed. The convention transplants cleanly, with one wrinkle specific to this blog: drafts must not consume episode numbers, so the number comes from position in the <em>full</em> list while only published posts get rendered.</p>
<p><strong>The <a href="https://www.rssboard.org/rss-profile">RSS Best Practices Profile</a></strong> says <code>content:encoded</code> carries the article body. Mine carried this:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">htmlContent</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> fs</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">readFileSync</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">postPath</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'utf8'</span><span style="color:#ABB2BF">);  </span><span style="color:#7F848E;font-style:italic">// the entire built page</span></span>
<span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> contentEncoded</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `&#x3C;![CDATA[</span><span style="color:#C678DD">${</span><span style="color:#E06C75">htmlContent</span><span style="color:#C678DD">}</span><span style="color:#98C379">]]]]><![CDATA[>`</span><span style="color:#ABB2BF">;</span></span></code></pre></div>

<p>The whole document. <code>&lt;head&gt;</code>, inline <code>&lt;style&gt;</code>, the nav, the footer, the scripts — wrapped in CDATA, once per item. Even setting aside the size, it&#39;s the wrong content: a feed reader wants the post, not my site chrome.</p>
<p>It also emitted <code>&lt;author&gt;Jeffrey Jose&lt;/author&gt;</code>, which is invalid — RSS requires an email address there. And <code>lastBuildDate</code> was a wall-clock timestamp, so the moment the feed became a committed file it would have churned on every single build. It&#39;s now derived from the newest post.</p>
<h2>The part where I deleted five live pages</h2>
<p>Adding drafts created a leak: a draft rendered by a local build stays in <code>public/blog/</code> and gets committed. So I wrote a prune step — delete any directory that isn&#39;t a live post.</p>
<p>It worked. It also printed this:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>🧹 Removed orphaned output: adding-text-based-visualizers-to-the-blog-homepage/</span></span>
<span class="line"><span>🧹 Removed orphaned output: building-a-scalable-chat-platform-with-claude-code/</span></span>
<span class="line"><span>🧹 Removed orphaned output: deploying-a-scalable-chat-platform-to-render/</span></span>
<span class="line"><span>🧹 Removed orphaned output: images/</span></span></code></pre></div>

<p><code>images/</code> is an asset directory. The others are live URLs.</p>
<p><code>git checkout</code> got them back, and the diagnosis was more interesting than the mistake. Four of those posts carry an explicit <code>slug:</code> in frontmatter that overrides the title-derived one:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">title</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"Adding Text-Based Visualizers to the Blog Homepage"</span></span>
<span class="line"><span style="color:#E06C75">slug</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">ascii-visualizers</span></span></code></pre></div>

<p>The directories named after their <em>titles</em> were left over from before those slugs were added — real pages, still served, duplicating canonical content. A fifth was published under an older title. My prune was right that they were orphans and wrong about the remedy: an orphaned URL that has already been served needs a redirect, not a delete.</p>
<p>So the prune now only removes directories git doesn&#39;t track, and refuses to run at all if it can&#39;t consult git:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> (</span><span style="color:#E5C07B">tracked</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">has</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">name</span><span style="color:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#E5C07B">    console</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">warn</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">`⚠️  </span><span style="color:#C678DD">${</span><span style="color:#E06C75">name</span><span style="color:#C678DD">}</span><span style="color:#98C379">/ is published but no post or redirect owns it`</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#C678DD">    continue</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>A build script that deletes things needs a definition of &quot;safe to delete&quot; that comes from outside its own inference. &quot;Not in my current list&quot; isn&#39;t it — my current list is exactly the thing that changed.</p>
<p>Those five stale pages are now redirects, which is a better outcome than either leaving them or deleting them.</p>
<h2>Fixing the slugs, and paying for it</h2>
<p>Which brings up the other thing the audit turned up: 11 of 36 URLs were sliced mid-word by a hard <code>substring(0, 50)</code>.</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>/blog/building-a-secure-snake-game-with-terminal-aesthet</span></span>
<span class="line"><span>/blog/ai-as-learning-catalyst-how-to-gain-skills-without</span></span></code></pre></div>

<p>Cutting on a word boundary instead is trivial. Doing it to <em>published</em> URLs is not — those paths have inbound links, and a static host has no 301.</p>
<p>The approach: <code>blog/redirects.json</code> is a committed, append-only record of retired URLs, generated by replaying every historical slug algorithm against every current title and recording what moved. The build emits a meta-refresh plus <code>rel=&quot;canonical&quot;</code> stub at each old path, and CI fails if the file is stale:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">- </span><span style="color:#E06C75">name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Check redirect map is current</span></span>
<span class="line"><span style="color:#E06C75">  run</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">npm run blog:redirects:check</span></span></code></pre></div>

<p>34 entries: 11 from the truncation fix, 23 from title and slug changes going back to episode 003 that had been quietly serving duplicate pages this whole time.</p>
<h2>Determinism as a test</h2>
<p>The check I&#39;d have wanted from the start, now running in CI:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">BEFORE</span><span style="color:#56B6C2">=</span><span style="color:#ABB2BF">$(</span><span style="color:#61AFEF">find</span><span style="color:#98C379"> public/blog</span><span style="color:#D19A66"> -type</span><span style="color:#98C379"> f</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">sort</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">xargs</span><span style="color:#98C379"> shasum</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">shasum</span><span style="color:#ABB2BF">)</span></span>
<span class="line"><span style="color:#E06C75">SKIP_THUMBNAILS</span><span style="color:#56B6C2">=</span><span style="color:#98C379">true</span><span style="color:#61AFEF"> npm</span><span style="color:#98C379"> run</span><span style="color:#98C379"> build:blog</span><span style="color:#ABB2BF"> ></span><span style="color:#98C379">/dev/null</span><span style="color:#ABB2BF"> 2>&#x26;1</span></span>
<span class="line"><span style="color:#E06C75">AFTER</span><span style="color:#56B6C2">=</span><span style="color:#ABB2BF">$(</span><span style="color:#61AFEF">find</span><span style="color:#98C379"> public/blog</span><span style="color:#D19A66"> -type</span><span style="color:#98C379"> f</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">sort</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">xargs</span><span style="color:#98C379"> shasum</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">shasum</span><span style="color:#ABB2BF">)</span></span>
<span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> [ </span><span style="color:#98C379">"</span><span style="color:#E06C75">$BEFORE</span><span style="color:#98C379">"</span><span style="color:#56B6C2"> !=</span><span style="color:#98C379"> "</span><span style="color:#E06C75">$AFTER</span><span style="color:#98C379">"</span><span style="color:#ABB2BF"> ]; </span><span style="color:#C678DD">then</span></span>
<span class="line"><span style="color:#56B6C2">  echo</span><span style="color:#98C379"> "::error::Blog build is not deterministic — a second run changed the output."</span></span>
<span class="line"><span style="color:#56B6C2">  exit</span><span style="color:#D19A66"> 1</span></span>
<span class="line"><span style="color:#C678DD">fi</span></span></code></pre></div>

<p>Two consecutive builds must be byte-identical, and a local build must match a CI build exactly. Both now hold:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>local run 1: 6f2d1e27367c73695160baea06902053a0c26b1a</span></span>
<span class="line"><span>local run 2: 6f2d1e27367c73695160baea06902053a0c26b1a</span></span>
<span class="line"><span>CI    run 3: 6f2d1e27367c73695160baea06902053a0c26b1a</span></span></code></pre></div>

<p>That single property would have caught the wall-clock <code>lastmod</code>, the mutated shared array, and the local/CI divergence — three of the bugs in this post — without my knowing any of them existed. It&#39;s the cheapest test in the whole build.</p>
<h2>The sitemap nobody could read</h2>
<p>Having made the sitemap honest, the obvious next step was to submit it to Search Console. That turned up one more instance of the same pattern — a file correctly generated, correctly served, and pointed at from the wrong place.</p>
<p><code>robots.txt</code> said this:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">Sitemap</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">https://jeffreyjose07.github.io/blog/sitemap.xml</span></span></code></pre></div>

<p>Every URL inside that sitemap is on <code>jeffreyjose07.is-a.dev</code>. The apex <code>github.io</code> host 301s here, so the declared location and the contents disagreed about which site they described.</p>
<p>That isn&#39;t cosmetic. A sitemap may only list URLs on the host that serves it; when the two differ it&#39;s a <strong>cross-domain sitemap</strong>, and it&#39;s ignored unless both properties are verified and cross-submission is configured. So the one discovery mechanism that works without any manual submission was pointing crawlers at a file they would then decline to use.</p>
<p>Two changes: point <code>robots.txt</code> at the canonical host, and write the sitemap to <code>/sitemap.xml</code> as well as <code>/blog/sitemap.xml</code>. The root path is where crawlers and tooling probe by default. The <code>/blog/</code> copy stays because it&#39;s already indexed — same generator, same bytes.</p>
<p>The manual route has also quietly changed. Google <a href="https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping">deprecated the sitemaps ping endpoint</a> in 2023, and <code>google.com/ping?sitemap=</code> now returns 404. If you have a <code>postbuild</code> script still calling it, it isn&#39;t submitting anything. <code>robots.txt</code> and Search Console are the only two mechanisms left.</p>
<h2>The SPA that verifies everything</h2>
<p>Search Console verification for a URL-prefix property means hosting a file at a path Google names, containing a string Google names. It looks trivial. For a single-page app it is a trap.</p>
<p>This site&#39;s build ends with:</p>
<div class="code-block-wrap" data-lang="json"><span class="code-lang" aria-hidden="true">json</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">"build"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"vite build &#x26;&#x26; cp dist/index.html dist/404.html"</span></span></code></pre></div>

<p>That&#39;s what makes client-side routing work on GitHub Pages: any unknown path returns the app shell so React Router can take over. Which means a request for a file that <em>isn&#39;t there</em> returns <strong>HTTP 200 with a complete HTML document</strong>. Not a 404. A confident, successful-looking page.</p>
<p>So the check that matters isn&#39;t &quot;does the URL return 200&quot; — it will, whether or not the file exists. It&#39;s whether the response is the file:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sL</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> /dev/null</span><span style="color:#D19A66"> -w</span><span style="color:#98C379"> "HTTP %{http_code}  %{size_download}b\n"</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#98C379">  https://jeffreyjose07.is-a.dev/googlea374c01db1a7aeca.html</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># HTTP 200  53b</span></span>
<span class="line"></span>
<span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sL</span><span style="color:#98C379"> https://jeffreyjose07.is-a.dev/googlea374c01db1a7aeca.html</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#ABB2BF">  | </span><span style="color:#61AFEF">grep</span><span style="color:#D19A66"> -c</span><span style="color:#98C379"> "&#x3C;!DOCTYPE html>"</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># 0</span></span></code></pre></div>

<p>53 raw bytes and zero doctypes. Had the file been misplaced, the first command would still have printed <code>200</code> — just with a few kilobytes of app shell — and verification would have failed with a &quot;content doesn&#39;t match&quot; error pointing at nothing obviously wrong.</p>
<p>Same lesson as the feed, one layer out: a 200 is not evidence that the thing you asked for is what came back.</p>
<p>Submitted, and the number is the point:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>/sitemap.xml    Status: Success    Discovered pages: 40</span></span></code></pre></div>

<p>40 is exactly what <code>grep -c &quot;&lt;loc&gt;&quot;</code> reported against the live file. When the artifact and the consumer agree on a count, the pipeline is intact end to end — which is a better result than &quot;Success&quot; on its own, because &quot;Success&quot; is just another green light.</p>
<p>Two episodes ago I was auditing docs that outlived their code. The build system had the same disease, one layer down: it <em>said</em> it produced a feed, a sitemap, a search index. Two of those three were lying, and the exit code was 0 the entire time.</p>
<p>Green builds are not evidence. Assert on the artifact.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The Two-Month Outage: Dead DNS, Eager Filters, and Two Kinds of Postgres URL]]></title>
      <description><![CDATA[scalable-chat-platform was down for two months and I did not notice. A free-tier Postgres powered off, its DNS record vanished, and a servlet filter took the whole app down with it — including the health endpoint that was supposed to tell me. Now with the Render-side postmortem: a 59-day hole in the deploy history, an empty healthCheckPath, and one of my own conclusions falsified.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/two-month-outage-aiven-dns-and-jdbc-urls</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/two-month-outage-aiven-dns-and-jdbc-urls</guid>
      <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[scalable-chat-platform]]></category>
      <category><![CDATA[postgresql]]></category>
      <category><![CDATA[aiven]]></category>
      <category><![CDATA[render]]></category>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[incident]]></category>
      <category><![CDATA[github-actions]]></category>
      <category><![CDATA[debugging]]></category>
      <category><![CDATA[deployment]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/two-month-outage-aiven-dns-and-jdbc-urls.png" />
      <content:encoded><![CDATA[<p>Working log for <a href="https://github.com/jeffreyjose07/scalable-chat-platform">scalable-chat-platform</a>. <strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/hardening-chat-platform-deps-health-ci">Hardening the Chat Platform: Deps, Deep Health, and CI Pings</a>.</p>
<p>In episode 027 I added a scheduled GitHub Action that pings <code>GET /api/health/status</code> every five minutes and fails loudly if any dependency is down. That was the right instinct. Then it went red on <strong>2026-06-03</strong> and stayed red for <strong>two months</strong>, firing every five minutes into a void, and I did not notice until I went looking for something else.</p>
<p>This is the post-mortem. It has three stacked failure modes, each one hiding the next, and each producing a completely different error at a different layer of the stack. The interesting part is not the fix — the fix was one environment variable. The interesting part is why an app with a carefully built three-dependency health check could not tell me which dependency had died.</p>
<hr>
<h2>The symptom: zero bytes</h2>
<p>The first thing I did was the obvious thing.</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sS</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> /dev/null</span><span style="color:#D19A66"> -w</span><span style="color:#98C379"> "HTTP %{http_code} time=%{time_total}s\n"</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#D19A66">  --max-time</span><span style="color:#D19A66"> 60</span><span style="color:#98C379"> https://scalable-chat-platform.onrender.com</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">curl</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">(28) Operation timed out after 60003 milliseconds with 0 bytes received</span></span>
<span class="line"><span style="color:#98C379">HTTP 000  time=60.003420s</span></span></code></pre></div>

<p><strong>Zero bytes.</strong> That detail matters. A crashed-but-running Spring Boot app gives you a 500 with a JSON error body. A container that died gives you Render&#39;s own 502 page, and quickly. Zero bytes after a full 60-second timeout means nothing upstream ever accepted the request — the container was not listening on 8080 at all.</p>
<p>The CI probe had been saying the same thing for two months, in a more compact form:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">curl</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">(28) Operation timed out after 45001 milliseconds with 0 bytes received</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">##[error]Process completed with exit code 28.</span></span></code></pre></div>

<h2>Dating the outage with <code>gh</code></h2>
<p>Before touching any config, I wanted two numbers: <em>when</em> did this start, and <em>which</em> dependency broke. The GitHub Actions history had both, for free.</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">gh</span><span style="color:#98C379"> run</span><span style="color:#98C379"> list</span><span style="color:#D19A66"> --workflow=</span><span style="color:#98C379">"ping-app-health.yml"</span><span style="color:#D19A66"> --status</span><span style="color:#98C379"> success</span><span style="color:#D19A66"> --limit</span><span style="color:#D19A66"> 3</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#D19A66">  --json</span><span style="color:#98C379"> createdAt</span><span style="color:#D19A66"> -q</span><span style="color:#98C379"> '.[].createdAt'</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#D19A66">2026-06-03T07:06:50Z</span></span>
<span class="line"><span style="color:#D19A66">2026-06-02T06:51:40Z</span></span>
<span class="line"><span style="color:#D19A66">2026-06-01T13:07:03Z</span></span></code></pre></div>

<p>Last green: <strong>June 3rd</strong>. Then:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">gh</span><span style="color:#98C379"> run</span><span style="color:#98C379"> list</span><span style="color:#D19A66"> --workflow=</span><span style="color:#98C379">"ping-app-health.yml"</span><span style="color:#D19A66"> --limit</span><span style="color:#D19A66"> 100</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#D19A66">  --json</span><span style="color:#98C379"> conclusion</span><span style="color:#D19A66"> -q</span><span style="color:#98C379"> '[.[].conclusion] | group_by(.) | map({(.[0]): length}) | add'</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="json"><span class="code-lang" aria-hidden="true">json</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">{</span><span style="color:#E06C75">"failure"</span><span style="color:#ABB2BF">:</span><span style="color:#D19A66">99</span><span style="color:#ABB2BF">,</span><span style="color:#E06C75">"startup_failure"</span><span style="color:#ABB2BF">:</span><span style="color:#D19A66">1</span><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>Ninety-nine consecutive failures. But the genuinely useful signal was the <em>other</em> workflow:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">2026-08-01T03:37  success   Keep Redis Alive</span></span>
<span class="line"><span style="color:#98C379">2026-07-31T19:43  success   Keep Redis Alive</span></span>
<span class="line"><span style="color:#98C379">2026-07-31T14:24  success   Keep Redis Alive</span></span></code></pre></div>

<p><code>Keep Redis Alive</code> connects to Upstash using the same <code>REDIS_URL</code> secret the app uses, and it had been <strong>green throughout the entire outage</strong>. That is an independent, credential-level proof that Redis was healthy. One <code>gh</code> command eliminated a third of the dependency surface before I read a single line of application log.</p>
<blockquote>
<p>Two scheduled workflows hitting two different layers turned out to be worth more than either alone. The app-level probe said &quot;something is broken.&quot; The dependency-level probe said &quot;and it is not Redis.&quot;</p>
</blockquote>
<h2>The log: a pool that starts and never finishes</h2>
<p>Render&#39;s log stream had the shape of the problem:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">05:49:31.658  INFO  o.h.e.t.j.p.i.JtaPlatformInitiator</span><span style="color:#ABB2BF">  : </span><span style="color:#E06C75">HHH000489</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">No JTA platform available</span></span>
<span class="line"><span style="color:#E06C75">05:49:31.769  INFO  com.zaxxer.hikari.HikariDataSource</span><span style="color:#ABB2BF">  : </span><span style="color:#98C379">HikariPool-1 - Starting...</span></span>
<span class="line"><span style="color:#E06C75">06:38:18.723  OpenJDK 64-Bit Server VM warning</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Options -Xverify:none and -noverify were deprecated</span></span></code></pre></div>

<p><code>HikariPool-1 - Starting...</code> and then <strong>nothing</strong> — until a fresh JVM boot 49 minutes later. The line that should follow, and never did, is <code>HikariPool-1 - Start completed.</code> The application was dying between those two log lines, over and over, in a restart loop.</p>
<p>The full stack trace bottomed out at the only line that mattered:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>Caused by: java.net.UnknownHostException: pg-xxxxxxxx-chat-platform-db.g.aivencloud.com</span></span>
<span class="line"><span>    at java.base/sun.nio.ch.NioSocketImpl.connect(Unknown Source)</span></span>
<span class="line"><span>    at org.postgresql.core.PGStream.createSocket(PGStream.java:243)</span></span>
<span class="line"><span>    at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:132)</span></span>
<span class="line"><span>    ...</span></span>
<span class="line"><span>    at com.zaxxer.hikari.pool.HikariPool.checkFailFast(HikariPool.java:561)</span></span></code></pre></div>

<p><code>UnknownHostException</code>. Not &quot;connection refused.&quot; Not &quot;authentication failed.&quot; Not &quot;timeout.&quot; <strong>The hostname did not resolve.</strong></p>
<p>I confirmed it from my laptop, outside Render entirely:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">dig</span><span style="color:#98C379"> +short</span><span style="color:#98C379"> pg-xxxxxxxx-chat-platform-db.g.aivencloud.com</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># (no output — NXDOMAIN)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#61AFEF">dig</span><span style="color:#98C379"> +short</span><span style="color:#98C379"> ep-old-breeze-a1lpt8gb-pooler.ap-southeast-1.aws.neon.tech</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># 52.220.170.93</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># 13.228.184.177</span></span></code></pre></div>

<p>The old Neon endpoint from <a href="https://jeffreyjose07.is-a.dev/blog/from-neon-to-supabase-to-aiven-postgresql-migration">episode 014&#39;s migration odyssey</a> still resolved. The current Aiven one had no DNS record at all.</p>
<h3>Why the record disappeared</h3>
<p>Aiven&#39;s free tier powers off a service after a period with no client activity. When it powers off, the nodes are released and <strong>the DNS record goes with them</strong>. The service still exists in the console, your data still exists, but the hostname stops resolving until you power it back on.</p>
<p>This is a meaningfully different failure from the one you plan for. Everyone writes retry logic for &quot;database is slow&quot; or &quot;connection refused.&quot; Almost nobody writes it for &quot;the hostname ceased to exist,&quot; because in a normal production environment hostnames do not spontaneously stop resolving. Free tiers are not a normal production environment.</p>
<hr>
<h2>The real bug: why the <em>whole app</em> died</h2>
<p>Here is the part I actually got wrong, and the part worth internalising.</p>
<p><code>application-render.yml</code> opens with what I thought was insurance:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">spring</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">  main</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">    lazy-initialization</span><span style="color:#ABB2BF">: </span><span style="color:#D19A66">true</span></span></code></pre></div>

<p>Lazy initialization is supposed to mean beans are created on first use, not at startup. My mental model was that a dead Postgres would produce a degraded app: the UI would load, static assets would serve, non-DB endpoints would answer, and only the database-backed paths would fail.</p>
<p>That is not what happened, and the stack trace explains why:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>Error creating bean with name 'securityConfig'</span></span>
<span class="line"><span>  → Unsatisfied dependency: 'jwtAuthenticationFilter'</span></span>
<span class="line"><span>    → Unsatisfied dependency: 'userService'</span></span>
<span class="line"><span>      → Unsatisfied dependency: 'userRepository'</span></span>
<span class="line"><span>        → Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory'</span></span>
<span class="line"><span>          → Unable to build Hibernate SessionFactory</span></span>
<span class="line"><span>            → JDBCConnectionException: Unable to open JDBC Connection for DDL execution</span></span></code></pre></div>

<p>Read that chain bottom-up and the trap is obvious. <code>JwtAuthenticationFilter</code> is a <strong>servlet filter</strong>. Servlet filters are <code>ServletContextInitializer</code> beans, and Tomcat must instantiate every one of them at context startup to build the filter chain — there is no &quot;first use&quot; to defer to, because the filter chain <em>is</em> the thing being constructed. Spring has no choice but to create it eagerly.</p>
<p>Creating the filter requires <code>UserService</code>, which requires <code>UserRepository</code>, which requires the <code>EntityManagerFactory</code>, which requires a live JDBC connection because <code>ddl-auto: update</code> makes Hibernate run schema introspection at boot.</p>
<p><strong>So <code>lazy-initialization: true</code> bought me nothing.</strong> One unreachable database took down Tomcat itself.</p>
<h3>The health endpoint could never have saved me</h3>
<p>This is the sharpest lesson in the whole incident.</p>
<p><code>HealthController</code> is genuinely good code. It probes all three stores, measures response times, caches results with a shorter TTL while unhealthy so recovery is noticed fast, and returns a proper non-200 when anything is down:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">RestController</span></span>
<span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">RequestMapping</span><span style="color:#E06C75">(</span><span style="color:#98C379">"/api/health"</span><span style="color:#E06C75">)</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> class</span><span style="color:#E5C07B"> HealthController</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#ABB2BF">    @</span><span style="color:#E5C07B">Autowired</span><span style="color:#C678DD"> private</span><span style="color:#E5C07B"> DataSource</span><span style="color:#E06C75"> dataSource</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    @</span><span style="color:#E5C07B">Autowired</span><span style="color:#C678DD"> private</span><span style="color:#E5C07B"> MongoTemplate</span><span style="color:#E06C75"> mongoTemplate</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    @</span><span style="color:#E5C07B">Autowired</span><span style="color:#C678DD"> private</span><span style="color:#E5C07B"> RedisTemplate</span><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E5C07B">String</span><span style="color:#ABB2BF">,</span><span style="color:#E5C07B"> String</span><span style="color:#ABB2BF">></span><span style="color:#E06C75"> redisTemplate</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">    // ... deep probe with caching</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>It never ran. Not once in two months. Tomcat never finished starting, so no controller was ever mapped, so the endpoint that exists specifically to tell me &quot;postgresql: DOWN&quot; could not be reached <strong>precisely when it had something to say</strong>.</p>
<blockquote>
<p>A health check that shares a fate with the thing it monitors is not a health check. It is a second copy of the outage.</p>
</blockquote>
<p>That is why the probe got zero bytes instead of a JSON body naming the dead dependency. The monitoring was structurally incapable of reporting the most important failure mode it had.</p>
<hr>
<h2>Fix, part one: power on</h2>
<p>The recovery itself was anticlimactic. Power the service back on in the Aiven console, then verify from outside:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">dig</span><span style="color:#98C379"> +short</span><span style="color:#98C379"> pg-xxxxxxxx-chat-platform-db.g.aivencloud.com</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># 165.22.223.111</span></span>
<span class="line"></span>
<span class="line"><span style="color:#61AFEF">nc</span><span style="color:#D19A66"> -z</span><span style="color:#D19A66"> -v</span><span style="color:#D19A66"> -G</span><span style="color:#D19A66"> 8</span><span style="color:#98C379"> pg-xxxxxxxx-chat-platform-db.g.aivencloud.com</span><span style="color:#D19A66"> 24531</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># Connection to ... port 24531 [tcp/*] succeeded!</span></span></code></pre></div>

<p>DNS back, TCP open. Then the question that actually mattered: <strong>did the data survive?</strong></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">psql</span><span style="color:#98C379"> "host=... port=24531 dbname=defaultdb user=avnadmin sslmode=require"</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#D19A66">  -Atc</span><span style="color:#98C379"> "SELECT table_name FROM information_schema.tables WHERE table_schema='public';"</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>conversation_participants</span></span>
<span class="line"><span>conversations</span></span>
<span class="line"><span>users</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>users|8</span></span>
<span class="line"><span>conversations|5</span></span></code></pre></div>

<p>All three tables, all rows intact, PostgreSQL 15.18. A power-off is not a deletion — the volume is preserved. Worth knowing before you panic.</p>
<h2>The hazard I braced for, and did not actually hit</h2>
<p><strong>Correction, added after pulling the Render deploy history — see the postmortem section below.</strong> When I first wrote this I expected the next step to be fixing <code>DATABASE_URL</code>, and I said so. The deploy record shows I was wrong: the environment variable was already correct, and powering Aiven back on was the <em>entire</em> fix. I am leaving this section in because the trap is real and will bite anyone migrating providers — but it was not what broke this deployment.</p>
<p>Aiven hands you a connection string in this shape:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">postgres://avnadmin:PASSWORD@pg-xxxxxxxx-chat-platform-db.g.aivencloud.com:24531/defaultdb?sslmode=require</span></span></code></pre></div>

<p>That is a <strong>libpq URI</strong>. It is what <code>psql</code>, <code>pg_dump</code>, and most non-JVM drivers expect. Paste it straight into Render&#39;s <code>DATABASE_URL</code> and the app still will not boot, because <code>application-render.yml</code> passes it through unmodified:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">spring</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">  datasource</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">    url</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">${DATABASE_URL}</span></span>
<span class="line"><span style="color:#E06C75">    driver-class-name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">org.postgresql.Driver</span></span></code></pre></div>

<p>Spring hands <code>spring.datasource.url</code> to <code>DriverManager</code>, which selects a driver by asking each registered one <code>acceptsURL(url)</code>. <code>org.postgresql.Driver</code> only accepts strings beginning with <code>jdbc:postgresql:</code> or <code>jdbc:postgres:</code>. A bare <code>postgres://</code> URI matches <strong>no</strong> driver, so you get:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>Driver claims to not accept jdbcUrl</span></span></code></pre></div>

<p>...thrown before a single packet leaves the container. It never reaches DNS, never reaches TCP. A completely different error, at a completely different layer, that looks nothing like the problem you just fixed.</p>
<p>There is a second wrinkle: that profile sets no <code>spring.datasource.username</code> or <code>password</code>, so the credentials <strong>must</strong> live inside the URL as query parameters. The working value is:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">jdbc:postgresql://pg-xxxxxxxx-chat-platform-db.g.aivencloud.com:24531/defaultdb?ssl=require&#x26;user=avnadmin&#x26;password=***&#x26;autoCommit=false</span></span></code></pre></div>

<p>So the same database now needs <strong>two different strings</strong> depending on who is connecting:</p>
<table>
<thead>
<tr>
<th>Consumer</th>
<th>Format</th>
<th>Shape</th>
</tr>
</thead>
<tbody><tr>
<td>Render <code>DATABASE_URL</code> (Spring)</td>
<td>JDBC</td>
<td><code>jdbc:postgresql://host:port/db?user=…&amp;password=…</code></td>
</tr>
<tr>
<td>GitHub secret (<code>psql</code>)</td>
<td>libpq URI</td>
<td><code>postgres://user:pass@host:port/db?sslmode=require</code></td>
</tr>
</tbody></table>
<p>Writing that table down and keeping it somewhere is the actual deliverable of this section — just not, as it turned out, the fix for this incident.</p>
<h2>Fix, part three: a truncated secret</h2>
<p>With the app back up, I added a keep-alive workflow (below) and ran it. It failed:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">psql</span><span style="color:#ABB2BF">: </span><span style="color:#E06C75">error</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">connection to server at "pg-xxxxxxxx-chat-platform-db.g.aivencloud.com"</span></span>
<span class="line"><span style="color:#E06C75">(165.22.223.111), port 24531 failed</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">FATAL</span><span style="color:#ABB2BF">:  </span><span style="color:#98C379">password authentication failed for user "avnadmin"</span></span></code></pre></div>

<p>Third failure mode, third layer. And notice how much this error tells you: DNS resolved (it printed the IP), TCP connected, TLS negotiated, the server parsed the username. Everything worked <em>except</em> the credential. That is a one-value fix, not a design problem.</p>
<p>The cause was mundane and entirely self-inflicted — my terminal had been mangling multi-line pastes all session, and the secret had been silently truncated on the way in. The robust fix avoids the interactive paste prompt entirely:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#56B6C2">printf</span><span style="color:#98C379"> 'postgres://avnadmin:PASSWORD@HOST:24531/defaultdb?sslmode=require'</span><span style="color:#ABB2BF"> > </span><span style="color:#98C379">/tmp/pg_uri.txt</span></span>
<span class="line"><span style="color:#61AFEF">gh</span><span style="color:#98C379"> secret</span><span style="color:#98C379"> set</span><span style="color:#98C379"> AIVEN_POSTGRES_URI</span><span style="color:#D19A66"> --repo</span><span style="color:#98C379"> user/repo</span><span style="color:#ABB2BF"> &#x3C; </span><span style="color:#98C379">/tmp/pg_uri.txt</span><span style="color:#ABB2BF"> &#x26;&#x26; </span><span style="color:#61AFEF">rm</span><span style="color:#D19A66"> -P</span><span style="color:#98C379"> /tmp/pg_uri.txt</span></span></code></pre></div>

<p><code>printf</code> rather than <code>echo</code> is deliberate: <code>echo</code> appends a trailing newline, and a stray <code>\n</code> inside a connection URI is its own subtle authentication failure.</p>
<hr>
<h2>Verification</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sS</span><span style="color:#98C379"> https://scalable-chat-platform.onrender.com/api/health/status</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="json"><span class="code-lang" aria-hidden="true">json</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">{</span></span>
<span class="line"><span style="color:#E06C75">  "service"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"chat-platform-backend"</span><span style="color:#ABB2BF">,</span></span>
<span class="line"><span style="color:#E06C75">  "status"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"UP"</span><span style="color:#ABB2BF">,</span></span>
<span class="line"><span style="color:#E06C75">  "dependencies"</span><span style="color:#ABB2BF">: {</span></span>
<span class="line"><span style="color:#E06C75">    "postgresql"</span><span style="color:#ABB2BF">: { </span><span style="color:#E06C75">"responseTime"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"82ms"</span><span style="color:#ABB2BF">,   </span><span style="color:#E06C75">"details"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"Connection successful"</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">"status"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"UP"</span><span style="color:#ABB2BF"> },</span></span>
<span class="line"><span style="color:#E06C75">    "mongodb"</span><span style="color:#ABB2BF">:    { </span><span style="color:#E06C75">"responseTime"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"64ms"</span><span style="color:#ABB2BF">,   </span><span style="color:#E06C75">"details"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"Ping successful"</span><span style="color:#ABB2BF">,       </span><span style="color:#E06C75">"status"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"UP"</span><span style="color:#ABB2BF"> },</span></span>
<span class="line"><span style="color:#E06C75">    "redis"</span><span style="color:#ABB2BF">:      { </span><span style="color:#E06C75">"responseTime"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"4046ms"</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">"response"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"PONG"</span><span style="color:#ABB2BF">,                 </span><span style="color:#E06C75">"status"</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">"UP"</span><span style="color:#ABB2BF"> }</span></span>
<span class="line"><span style="color:#ABB2BF">  }</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>And the CI probe, green for the first time since June 3rd:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">overall</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">UP</span></span>
<span class="line"><span style="color:#E06C75">  postgresql</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">UP</span></span>
<span class="line"><span style="color:#E06C75">  mongodb</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">UP</span></span>
<span class="line"><span style="color:#E06C75">  redis</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">UP</span></span></code></pre></div>

<h3>An unrelated bug the health JSON just handed me</h3>
<p>Look again at those response times. Postgres: <strong>82ms</strong>. Mongo: <strong>64ms</strong>. Redis: <strong>4046ms</strong>.</p>
<p>Redis is a thousand times slower than Postgres for a single <code>PING</code>. That is an Upstash free-tier cold start. Now compare it to the timeout configured in the same profile:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">spring</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">  redis</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">    timeout</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">2000ms</span></span></code></pre></div>

<p>The configured timeout is <strong>2 seconds</strong>. The observed cold-start latency is <strong>4 seconds</strong>. A user request that lands on a cold Redis will time out where my health check succeeded — because the health check ran first and warmed the connection. That is a latent production bug I would not have found without printing per-dependency timings, and it is a good argument for putting response times in health payloads even when everything says <code>UP</code>.</p>
<h2>The Render-side postmortem</h2>
<p>Everything above was reconstructed from application logs, GitHub Actions history, and probes from my laptop. Afterwards I connected Render&#39;s MCP server and pulled the platform&#39;s own record. It confirmed the diagnosis, corrected one of my conclusions, and surfaced three configuration problems I did not know I had.</p>
<h3>The deploy history contains a two-month hole</h3>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>dep-d8ftrdho3t8c738fk1f0   2026-06-03T08:00:28Z   live → deactivated</span></span>
<span class="line"><span>                           ↑ commit 0582d37</span></span>
<span class="line"><span>        ...nothing at all for 59 days...</span></span>
<span class="line"><span>dep-d9mr0k2jnfac739p45hg   2026-08-01T08:42:17Z   manual redeploy</span></span>
<span class="line"><span>dep-d9mr51nlk1mc738orheg   2026-08-01T08:48:56Z   live</span></span></code></pre></div>

<p>That June 3rd deploy <strong>succeeded</strong>. It built, it started, Render marked it live, and it stayed the active deploy for the entire outage. Nothing was ever redeployed, rolled back, or reconfigured in between. The code that ran perfectly on June 3rd is byte-identical to the code that could not boot on August 1st — which is exactly what you would expect when the thing that changed is external.</p>
<h3>The correction: <code>DATABASE_URL</code> was never wrong</h3>
<p>Here is the measurement that overturned my assumption. That manual redeploy on August 1st at 08:37 was triggered <strong>after</strong> I powered Aiven back on but <strong>before</strong> any environment variable was touched. It ran the unchanged June 3rd commit. And:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">08:41:41.274  HikariPool-1 - Starting...</span></span>
<span class="line"><span style="color:#98C379">08:41:44.032  HikariPool-1 - Start completed.</span></span></code></pre></div>

<p>Two point seven seconds. Same code, same environment variables, same everything — the only difference in the universe was that the database hostname resolved again.</p>
<p>So the JDBC-versus-libpq section above describes a genuine hazard that I did not actually hit. The deployed <code>DATABASE_URL</code> had been in correct JDBC form since the Aiven migration. I had predicted a second failure mode and it simply was not there. Worth stating plainly, because a post-mortem that quietly keeps its wrong predictions is worth much less than one that marks them.</p>
<h3><code>render.yaml</code> is not the source of truth</h3>
<p>The live service configuration disagrees with the <code>render.yaml</code> committed in the repository on three separate points:</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th><code>render.yaml</code> says</th>
<th>Render actually has</th>
</tr>
</thead>
<tbody><tr>
<td><code>plan</code></td>
<td><code>starter</code> (commented &quot;Upgraded from free&quot;)</td>
<td><strong><code>free</code></strong></td>
</tr>
<tr>
<td><code>region</code></td>
<td><code>oregon</code></td>
<td><strong><code>singapore</code></strong></td>
</tr>
<tr>
<td><code>healthCheckPath</code></td>
<td><code>/api/health/status</code></td>
<td><strong><code>&quot;&quot;</code> (empty)</strong></td>
</tr>
</tbody></table>
<p>This is the trap of Blueprint files: Render only applies <code>render.yaml</code> when the service is <strong>created from a Blueprint</strong>. This service was created through the dashboard, so the file has been decorative from day one. I have been editing a configuration file for two months believing it configured something.</p>
<p>The third row is the one that matters. <strong>Render was never health-checking this service.</strong> With <code>healthCheckPath</code> empty it only verifies that something binds the port at deploy time; it never probes the running app afterwards. Combined with the app dying at startup, Render had no mechanism to notice, mark the service unhealthy, or alert me — and the deploy stayed flagged &quot;live&quot; for two months while nothing worked.</p>
<h3>The <code>free</code> plan explains the zero bytes</h3>
<p><code>plan: free</code> also resolves the symptom I opened with. Free web services spin down after inactivity and cold-start on the next request. This app takes roughly 45–60 seconds to boot when everything is healthy — Spring context, JPA, three connection pools. My health probe uses <code>curl --max-time 45</code>.</p>
<p>That is a race the probe cannot reliably win even against a <em>healthy</em> app, and cannot win at all against one that crash-loops. Rather than a fast 502 from a dead upstream, each request sat waiting on a container that was still starting, until curl gave up. Hence zero bytes at 45 seconds rather than an error page.</p>
<h3>The platform metrics were empty, and that is the finding</h3>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>instance_count      → []</span></span>
<span class="line"><span>http_request_count  → []</span></span></code></pre></div>

<p>Not zero — <strong>empty</strong>, for the full 30-day retention window. No instances recorded, no requests recorded. The service was so consistently down that Render&#39;s own telemetry had nothing to plot. An empty metrics response is easy to read as &quot;monitoring is broken&quot;; here it was the most concise possible statement of the outage.</p>
<p>The memory series did have data, and it draws the crash loop precisely:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#98C379">instance -6z9m7   07:55  131.8 MB    ← JVM starting up</span></span>
<span class="line"><span style="color:#98C379">                  08:00   54.0 MB    ← died, fresh process</span></span>
<span class="line"><span style="color:#98C379">                  08:05  130.2 MB    ← starting up again</span></span>
<span class="line"><span style="color:#98C379">instance -lznsj   08:50  256.9 MB    ← recovered</span></span>
<span class="line"><span style="color:#98C379">                  08:55  257.3 MB</span></span>
<span class="line"><span style="color:#98C379">                  09:00  258.6 MB    ← flat = healthy</span></span></code></pre></div>

<p>A sawtooth is a crash loop. A flat line is a running application. In the same window Render&#39;s logs carry twelve <code>UnknownHostException</code> events between 07:51:44 and 08:02:36 — arriving in pairs about seventy seconds apart, which is one restart cycle.</p>
<h3>The last deploy was an accident</h3>
<p>A small irony in the record: the deploy that finally went live (<code>dep-d9mr51nlk1mc738orheg</code>, 08:47) was triggered by <code>new_commit</code> — the push of the keep-alive workflow described below. Auto-deploy is on for <code>main</code>, so committing the <em>prevention</em> for this incident is what shipped the <em>recovery</em> for it.</p>
<h2>Prevention</h2>
<p>The keep-alive workflow, modelled directly on the Redis one that had been quietly proving its worth all along:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Keep Postgres Alive</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D19A66">on</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">  schedule</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#ABB2BF">    - </span><span style="color:#E06C75">cron</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">'30 */6 * * *'</span></span>
<span class="line"><span style="color:#E06C75">  workflow_dispatch</span><span style="color:#ABB2BF">:</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E06C75">jobs</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">  ping-postgres</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">    runs-on</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">ubuntu-latest</span></span>
<span class="line"><span style="color:#E06C75">    steps</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#ABB2BF">      - </span><span style="color:#E06C75">name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Install postgresql-client</span></span>
<span class="line"><span style="color:#E06C75">        run</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">sudo apt-get update &#x26;&#x26; sudo apt-get install -y postgresql-client</span></span>
<span class="line"><span style="color:#ABB2BF">      - </span><span style="color:#E06C75">name</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">Ping PostgreSQL</span></span>
<span class="line"><span style="color:#E06C75">        env</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">          PG_URI</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">${{ secrets.AIVEN_POSTGRES_URI }}</span></span>
<span class="line"><span style="color:#E06C75">        run</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">psql "$PG_URI" -Atc "SELECT now();"</span></span></code></pre></div>

<p>Two details that are easy to get wrong:</p>
<ol>
<li><strong>The cron is offset to <code>:30</code>.</strong> The Redis keep-alive runs at <code>0 */6</code>. Staggering them avoids two jobs contending for runners and hitting two providers in the same instant.</li>
<li><strong>Scheduled workflows only fire from the default branch.</strong> A cron sitting on a feature branch is completely inert, and GitHub gives you no warning about it. Merging to <code>main</code> is not cosmetic here — it is what arms the job.</li>
</ol>
<p>There is a third gotcha worth flagging: GitHub <strong>disables scheduled workflows after 60 days of repository inactivity</strong>. Dependabot keeps this repo busy enough that it has not bitten me, but if that ever stops, both keep-alives die silently — and then so does the database they protect.</p>
<hr>
<h2>What I would actually change</h2>
<p>The keep-alive is a workaround. It treats the symptom — an idle timer — rather than the disease, which is that <strong>Postgres is a hard single point of failure for a deployment that does not need it to be</strong>.</p>
<p>If I wanted this app to survive a database outage in degraded mode, the thing to fix is not connection pool settings. It is the dependency chain that pulls JPA into eager startup. Concretely:</p>
<ul>
<li>Break <code>JwtAuthenticationFilter</code>&#39;s compile-time dependency on <code>UserService</code> — inject an <code>ObjectProvider&lt;UserService&gt;</code> and resolve it per-request, so filter construction no longer forces the JPA graph.</li>
<li>Move off <code>ddl-auto: update</code> to a migration tool, so boot does not require a live connection for schema introspection.</li>
<li>Give the health endpoint its own dependency-free path, so it can report <code>postgresql: DOWN</code> instead of sharing the outage.</li>
</ul>
<p>Any one of those turns &quot;the entire site is a 60-second timeout&quot; into &quot;the site loads and says the database is down.&quot; That is a much better two months.</p>
<p>And three platform-side fixes the Render data made obvious, none of which involve code:</p>
<ul>
<li><strong>Set <code>healthCheckPath</code> on the actual service.</strong> It is empty today, so Render never probes the running app. Setting it to <code>/api/health/status</code> means a failed boot marks the deploy unhealthy instead of silently &quot;live&quot; — the single highest-value change on this list.</li>
<li><strong>Reconcile or delete <code>render.yaml</code>.</strong> A config file that configures nothing is worse than no file, because it invites you to &quot;fix&quot; production by editing it. Either recreate the service from the Blueprint so the file is authoritative, or delete it and treat the dashboard as the source of truth.</li>
<li><strong>Raise the probe timeout above the cold-start time</strong>, or move off the free plan. A 45-second timeout against a 45-to-60-second cold start produces failures that are indistinguishable from a real outage — which is precisely how a genuine outage hid in plain sight.</li>
</ul>
<h2>Lessons</h2>
<p><strong>A red check nobody looks at is not monitoring.</strong> The signal was there, correct and precise, every five minutes for two months. The gap was not detection — it was escalation. A failing scheduled workflow needs to reach a human, or it is just a log file with a nicer UI.</p>
<p><strong>Failure modes are legible by layer, if you read the exact exception.</strong> <code>UnknownHostException</code> (resolution) → <code>Driver claims to not accept jdbcUrl</code> (client-side URL parsing, never leaves the process) → <code>FATAL: password authentication failed</code> (full connection, rejected at auth). Three errors, three layers, three completely different fixes. Reading the precise wording is faster than changing several things and redeploying blind.</p>
<p><strong>Verify at each layer before moving to the next.</strong> <code>dig</code>, then <code>nc</code>, then <code>psql</code>, then the app. Each step took seconds and each one eliminated an entire category of cause. Fixing the environment variable and hitting redeploy would have conflated three separate problems into one confusing loop.</p>
<p><strong>Lazy initialization is not a resilience feature.</strong> It defers what it can. Servlet filters are not among them. If a bean ends up in the filter chain, everything it transitively depends on is a startup-time hard dependency, whatever the config says.</p>
<p><strong>Check what the platform thinks is true.</strong> I reconstructed this entire incident from logs and probes, and got the shape right — but I also carried a wrong assumption to the end, and I only found the empty <code>healthCheckPath</code>, the <code>free</code> plan, and the <code>render.yaml</code> drift by asking Render directly. Your infrastructure-as-code file describes what you <em>intended</em>. Only the platform knows what you <em>have</em>.</p>
<p><strong>Write down predictions so you can be shown wrong.</strong> I stated that <code>DATABASE_URL</code> was the remaining blocker. It was not. Because that prediction was specific, one deploy record was enough to falsify it. A vaguer claim would have quietly survived.</p>
<p>The app has been up since, the keep-alive is green, and the health endpoint is once again the first place I will look — assuming, this time, that something tells me to look.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[[object Object]: How One Dependabot Bump Broke 34 of 39 Posts]]></title>
      <description><![CDATA[A major bump to marked changed every renderer method to take a token object instead of positional strings. The build kept exiting 0 while emitting the literal text [object Object] into 34 of 39 posts — and a missing curl flag nearly convinced me nothing was wrong.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/object-object-marked-token-api</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/object-object-marked-token-api</guid>
      <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[blog]]></category>
      <category><![CDATA[marked]]></category>
      <category><![CDATA[javascript]]></category>
      <category><![CDATA[dependabot]]></category>
      <category><![CDATA[debugging]]></category>
      <category><![CDATA[build-tools]]></category>
      <category><![CDATA[node]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/object-object-marked-token-api.png" />
      <content:encoded><![CDATA[<p>Working log for this blog. <strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/two-month-outage-aiven-dns-and-jdbc-urls">The Two-Month Outage</a>.</p>
<p>I published <a href="https://jeffreyjose07.is-a.dev/blog/two-month-outage-aiven-dns-and-jdbc-urls">the outage post-mortem</a>, opened it to admire my work, and found this:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>The Two-Month Outage: Dead DNS, Eager Filters, and Two Kinds of Postgres URL</span></span>
<span class="line"><span></span></span>
<span class="line"><span>[object Object]</span></span>
<span class="line"><span></span></span>
<span class="line"><span>[object Object]</span></span>
<span class="line"><span></span></span>
<span class="line"><span>[object Object]</span></span></code></pre></div>

<p>Every paragraph. Every heading. The title rendered fine, the syntax-highlighted code blocks rendered perfectly, and everything in between was the four most demoralising words in JavaScript.</p>
<p>The reflex is to assume you broke the thing you just touched. That reflex was wrong, and checking it properly is most of this post.</p>
<hr>
<h2>First: measure the blast radius, not the symptom</h2>
<p>Before touching a line of code I wanted one number: <strong>how many posts are affected?</strong> If it were only the new post, the cause was my Markdown. If it were all of them, the cause was the build.</p>
<p>My first attempt to answer this was completely useless, and it is the most transferable lesson here:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sS</span><span style="color:#98C379"> https://jeffreyjose07.github.io/blog/two-month-outage-aiven-dns-and-jdbc-urls</span><span style="color:#56B6C2"> \</span></span>
<span class="line"><span style="color:#ABB2BF">  | </span><span style="color:#61AFEF">grep</span><span style="color:#D19A66"> -c</span><span style="color:#98C379"> "object Object"</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># 0</span></span></code></pre></div>

<p>Zero. On a page I could see with my own eyes was broken. Two separate mistakes stacked:</p>
<p><strong>Mistake one — no <code>-L</code>.</strong> This site is served from <code>jeffreyjose07.github.io</code> but redirects to the custom domain <a href="https://jeffreyjose07.is-a.dev/blog/adding-custom-domain-jeffreyjose07-is-a-dev">added back in episode 016</a>. Without <code>-L</code>, <code>curl</code> dutifully returns the <strong>301 redirect stub</strong> — a near-empty body with no content in it at all. I was grepping a redirect notice and concluding the site was healthy.</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sS</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> /dev/null</span><span style="color:#D19A66"> -w</span><span style="color:#98C379"> "%{redirect_url}\n"</span><span style="color:#98C379"> https://jeffreyjose07.github.io/blog/...</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic"># https://jeffreyjose07.is-a.dev/blog/...</span></span></code></pre></div>

<p><strong>Mistake two — <code>grep -c</code> counts lines, not matches.</strong> The build minifies HTML onto essentially one line. Even after adding <code>-L</code>, <code>grep -c</code> returned <code>1</code> for a page containing ninety-one occurrences. The fix is <code>grep -o … | wc -l</code>:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sSL</span><span style="color:#98C379"> "https://jeffreyjose07.is-a.dev/blog/</span><span style="color:#E06C75">$slug</span><span style="color:#98C379">"</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">grep</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> "object Object"</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">wc</span><span style="color:#D19A66"> -l</span></span></code></pre></div>

<p>With both fixed, the real numbers arrived:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>building-truthmeter-ai...          200</span></span>
<span class="line"><span>building-void-blocks...            186</span></span>
<span class="line"><span>implementing-privacy-first...      161</span></span>
<span class="line"><span>ai-as-learning-catalyst...         154</span></span>
<span class="line"><span>deploying-a-chat-platform...       139</span></span>
<span class="line"><span>...</span></span>
<span class="line"><span>two-month-outage-...                91   ← the "new" post</span></span></code></pre></div>

<p><strong>34 of 39 posts.</strong> My new post was not the worst offender — it was in the bottom half. This was a site-wide regression that had been live for who knows how long, and I only noticed because I happened to open a page right after publishing.</p>
<blockquote>
<p>A verification command that can return a false clean is worse than no verification. Both of my mistakes produced <em>reassuring</em> output.</p>
</blockquote>
<hr>
<h2>The tell: what survived</h2>
<p>Look closely at what was and was not broken, because the pattern names the culprit.</p>
<p>Broken — ordinary prose:</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">p</span><span style="color:#ABB2BF">>[object Object]&#x3C;/</span><span style="color:#E06C75">p</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<p>Not broken — fenced code blocks, fully syntax highlighted:</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"code-block-wrap"</span><span style="color:#D19A66"> data-lang</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"yaml"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">span</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"line"</span><span style="color:#ABB2BF">>&#x3C;</span><span style="color:#E06C75">span</span><span style="color:#D19A66"> style</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"color:#98C379"</span><span style="color:#ABB2BF">>HTTP 000  time=60.003420s&#x3C;/</span><span style="color:#E06C75">span</span><span style="color:#ABB2BF">>&#x3C;/</span><span style="color:#E06C75">span</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<p>And then the genuinely diagnostic one, a heading:</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">h2</span><span style="color:#ABB2BF">>[object Object]&#x3C;</span><span style="color:#E06C75">code</span><span style="color:#ABB2BF">>gh&#x3C;/</span><span style="color:#E06C75">code</span><span style="color:#ABB2BF">>&#x3C;/</span><span style="color:#E06C75">h2</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<p>That heading was <code>## Dating the outage with \</code>gh`<code>in Markdown. The inline</code><code>` element rendered <strong>correctly</strong>, sitting right next to a stringified object. So:</p>
<ul>
<li>The parser worked — it correctly identified a heading containing text plus inline code.</li>
<li>The heading renderer worked — it emitted <code>&lt;h2&gt;</code>, and it correctly delegated to its children.</li>
<li>The <code>codespan</code> renderer worked — <code>&lt;code&gt;gh&lt;/code&gt;</code> is perfect.</li>
<li>Something handling <strong>plain text</strong> returned an object.</li>
</ul>
<p>Partial, structured corruption like that points at a <strong>leaf renderer</strong>, not at the parser and not at the Markdown source. If my frontmatter or content were malformed, I would expect broken <em>structure</em> — a missing heading, an unclosed tag, content in the wrong place. Instead the structure was flawless and only the leaves were wrong.</p>
<hr>
<h2>Root cause: marked v9 changed every renderer signature</h2>
<p>The generator uses <a href="https://marked.js.org/">marked</a> with a few renderer overrides. Here is what <code>blog/scripts/build.js</code> had:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">markdown</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">use</span><span style="color:#ABB2BF">({</span></span>
<span class="line"><span style="color:#E06C75">    renderer</span><span style="color:#ABB2BF">: {</span></span>
<span class="line"><span style="color:#61AFEF">        text</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">            return</span><span style="color:#E5C07B"> colorizer</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">colorizeText</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">text</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#ABB2BF">        },</span></span>
<span class="line"><span style="color:#61AFEF">        image</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">href</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">title</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">            const</span><span style="color:#E5C07B"> titleAttr</span><span style="color:#56B6C2"> =</span><span style="color:#E06C75"> title</span><span style="color:#C678DD"> ?</span><span style="color:#98C379"> ` title="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">title</span><span style="color:#C678DD">}</span><span style="color:#98C379">"`</span><span style="color:#C678DD"> :</span><span style="color:#98C379"> ''</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">            const</span><span style="color:#E5C07B"> alt</span><span style="color:#56B6C2"> =</span><span style="color:#ABB2BF"> (</span><span style="color:#E06C75">text</span><span style="color:#56B6C2"> ||</span><span style="color:#98C379"> ''</span><span style="color:#ABB2BF">).</span><span style="color:#61AFEF">replace</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">/"/</span><span style="color:#C678DD">g</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'&#x26;quot;'</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#C678DD">            return</span><span style="color:#98C379"> `&#x3C;figure class="post-figure">&#x3C;img src="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">href</span><span style="color:#C678DD">}</span><span style="color:#98C379">" alt="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">alt</span><span style="color:#C678DD">}</span><span style="color:#98C379">">&#x3C;/figure>`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">        },</span></span>
<span class="line"><span style="color:#61AFEF">        paragraph</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">            return</span><span style="color:#98C379"> `&#x3C;p></span><span style="color:#C678DD">${</span><span style="color:#E06C75">text</span><span style="color:#C678DD">}</span><span style="color:#98C379">&#x3C;/p></span><span style="color:#56B6C2">\n</span><span style="color:#98C379">`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">        },</span></span>
<span class="line"><span style="color:#ABB2BF">    },</span></span>
<span class="line"><span style="color:#ABB2BF">});</span></span></code></pre></div>

<p>That code is correct for marked v4. It is silently wrong for marked v9 and later.</p>
<p><strong>Since marked v9, every renderer method receives a single token object instead of positional string arguments.</strong> The <code>paragraph</code> renderer no longer gets a rendered HTML string; it gets:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">{ </span><span style="color:#E06C75">type</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">'paragraph'</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">raw</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">'...'</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">text</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">'...'</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">tokens</span><span style="color:#ABB2BF">: [ </span><span style="color:#7F848E;font-style:italic">/* inline tokens */</span><span style="color:#ABB2BF"> ] }</span></span></code></pre></div>

<p>Interpolating that object into a template literal invokes <code>Object.prototype.toString</code>, which returns exactly <code>[object Object]</code>.</p>
<p>Note what <em>doesn't</em> happen: no exception, no warning, no type error. JavaScript will happily stringify any object into a template literal. The build ran to completion and exited 0.</p>
<p>The trigger was a Dependabot commit:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>9537801  Bump marked, react-helmet-async, and tailwind-merge majors.</span></span></code></pre></div>

<p><code>marked</code> went to <strong>18.0.7</strong>. The bump was merged, CI ran, CI passed, CI committed the regenerated HTML, and 34 posts quietly filled up with placeholder garbage.</p>
<h3>Why fenced code was immune</h3>
<p>Because it never goes through those overrides. Fenced blocks are handled by <code>marked-shiki</code>, registered as a <strong>separate extension</strong> with its own tokenizer and renderer:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">markdown</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">use</span><span style="color:#ABB2BF">(</span><span style="color:#61AFEF">markedShiki</span><span style="color:#ABB2BF">({</span></span>
<span class="line"><span style="color:#61AFEF">    highlight</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">code</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">lang</span><span style="color:#ABB2BF">) { </span><span style="color:#C678DD">return</span><span style="color:#61AFEF"> highlightCode</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">code</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">lang</span><span style="color:#ABB2BF">); },</span></span>
<span class="line"><span style="color:#ABB2BF">}));</span></span></code></pre></div>

<p>That extension claims the <code>code</code> token before the default renderer sees it, so it was entirely unaffected by the signature change. This is precisely why the damage was easy to miss on a casual glance — the visually loud parts of a technical blog, the syntax-highlighted code, all looked immaculate.</p>
<hr>
<h2>The fix</h2>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">// NOTE: since marked v9 every renderer method receives a single *token*</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">// object rather than positional string arguments. Destructuring the token</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">// (or re-parsing its inline children) is mandatory — interpolating the</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">// token itself renders the literal string "[object Object]".</span></span>
<span class="line"><span style="color:#E5C07B">markdown</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">use</span><span style="color:#ABB2BF">({</span></span>
<span class="line"><span style="color:#E06C75">    renderer</span><span style="color:#ABB2BF">: {</span></span>
<span class="line"><span style="color:#61AFEF">        image</span><span style="color:#ABB2BF">({ </span><span style="color:#E06C75;font-style:italic">href</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">title</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF"> }) {</span></span>
<span class="line"><span style="color:#C678DD">            const</span><span style="color:#E5C07B"> titleAttr</span><span style="color:#56B6C2"> =</span><span style="color:#E06C75"> title</span><span style="color:#C678DD"> ?</span><span style="color:#98C379"> ` title="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">title</span><span style="color:#C678DD">}</span><span style="color:#98C379">"`</span><span style="color:#C678DD"> :</span><span style="color:#98C379"> ''</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">            const</span><span style="color:#E5C07B"> alt</span><span style="color:#56B6C2"> =</span><span style="color:#ABB2BF"> (</span><span style="color:#E06C75">text</span><span style="color:#56B6C2"> ||</span><span style="color:#98C379"> ''</span><span style="color:#ABB2BF">).</span><span style="color:#61AFEF">replace</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">/"/</span><span style="color:#C678DD">g</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'&#x26;quot;'</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#C678DD">            return</span><span style="color:#98C379"> `&#x3C;figure class="post-figure">&#x3C;img src="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">href</span><span style="color:#C678DD">}</span><span style="color:#98C379">" alt="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">alt</span><span style="color:#C678DD">}</span><span style="color:#98C379">">&#x3C;/figure>`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">        },</span></span>
<span class="line"><span style="color:#61AFEF">        paragraph</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">token</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">            const</span><span style="color:#E5C07B"> text</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> colorizer</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">colorizeText</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">this</span><span style="color:#ABB2BF">.</span><span style="color:#E5C07B">parser</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">parseInline</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">token</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">tokens</span><span style="color:#ABB2BF">));</span></span>
<span class="line"><span style="color:#C678DD">            if</span><span style="color:#ABB2BF"> (</span><span style="color:#61AFEF">String</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">text</span><span style="color:#ABB2BF">).</span><span style="color:#61AFEF">trim</span><span style="color:#ABB2BF">().</span><span style="color:#61AFEF">startsWith</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">'&#x3C;figure class="post-figure">'</span><span style="color:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#C678DD">                return</span><span style="color:#98C379"> `</span><span style="color:#C678DD">${</span><span style="color:#E06C75">text</span><span style="color:#C678DD">}</span><span style="color:#56B6C2">\n</span><span style="color:#98C379">`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">            }</span></span>
<span class="line"><span style="color:#C678DD">            return</span><span style="color:#98C379"> `&#x3C;p></span><span style="color:#C678DD">${</span><span style="color:#E06C75">text</span><span style="color:#C678DD">}</span><span style="color:#98C379">&#x3C;/p></span><span style="color:#56B6C2">\n</span><span style="color:#98C379">`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">        },</span></span>
<span class="line"><span style="color:#ABB2BF">    },</span></span>
<span class="line"><span style="color:#ABB2BF">});</span></span></code></pre></div>

<p>Three changes, one of which is a deletion:</p>
<p><strong><code>image</code> — destructure the token.</strong> Mechanical. <code>image({ href, title, text })</code> reads almost identically to the old positional signature.</p>
<p><strong><code>paragraph</code> — re-parse the inline children.</strong> The token carries unrendered child tokens in <code>token.tokens</code>. To turn those into HTML you call <code>this.parser.parseInline(token.tokens)</code>. The <code>this.parser</code> binding is available inside renderer methods precisely for this, which is why these must stay ordinary functions and not arrow functions — an arrow function would capture the wrong <code>this</code> and fail.</p>
<p><strong><code>text</code> — deleted entirely.</strong> This one is worth dwelling on. The override existed to run prose through a semantic colorizer. Here is that colorizer:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">class</span><span style="color:#E5C07B"> SemanticColorizer</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#61AFEF">    colorizeText</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">        return</span><span style="color:#E06C75"> text</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>It is an <strong>identity function</strong>. The semantic coloring documented at length in <code>BLOG.md</code> had been hollowed out at some earlier point, leaving a renderer override whose entire contribution was to intercept every text node and hand it back unchanged. After the marked upgrade its only remaining effect was to corrupt the output.</p>
<p>Deleting it is strictly better than fixing it: marked's default <code>text</code> renderer also handles <strong>HTML escaping</strong>, which the override was bypassing. So removing dead code fixed the bug <em>and</em> closed an escaping gap.</p>
<p>Result:</p>
<div class="code-block-wrap" data-lang="yaml"><span class="code-lang" aria-hidden="true">yaml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E06C75">affected</span><span style="color:#ABB2BF">: </span><span style="color:#98C379">0 of 39</span></span></code></pre></div>

<p>Then, after deploying, a sweep of every live URL rather than a spot check:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">for</span><span style="color:#E06C75"> s</span><span style="color:#C678DD"> in</span><span style="color:#ABB2BF"> $(</span><span style="color:#61AFEF">ls</span><span style="color:#D19A66"> -d</span><span style="color:#98C379"> public/blog/</span><span style="color:#E5C07B">*</span><span style="color:#98C379">/</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">xargs</span><span style="color:#D19A66"> -n1</span><span style="color:#98C379"> basename</span><span style="color:#ABB2BF">); </span><span style="color:#C678DD">do</span></span>
<span class="line"><span style="color:#E06C75">  n</span><span style="color:#56B6C2">=</span><span style="color:#ABB2BF">$(</span><span style="color:#61AFEF">curl</span><span style="color:#D19A66"> -sSL</span><span style="color:#98C379"> "https://jeffreyjose07.is-a.dev/blog/</span><span style="color:#E06C75">$s</span><span style="color:#98C379">"</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">grep</span><span style="color:#D19A66"> -o</span><span style="color:#98C379"> "object Object"</span><span style="color:#ABB2BF"> | </span><span style="color:#61AFEF">wc</span><span style="color:#D19A66"> -l</span><span style="color:#ABB2BF">)</span></span>
<span class="line"><span style="color:#ABB2BF">  [ </span><span style="color:#98C379">"</span><span style="color:#E06C75">$n</span><span style="color:#98C379">"</span><span style="color:#56B6C2"> !=</span><span style="color:#98C379"> "0"</span><span style="color:#ABB2BF"> ] &#x26;&#x26; </span><span style="color:#56B6C2">echo</span><span style="color:#98C379"> "BROKEN (</span><span style="color:#E06C75">$n</span><span style="color:#98C379">): </span><span style="color:#E06C75">$s</span><span style="color:#98C379">"</span></span>
<span class="line"><span style="color:#C678DD">done</span></span></code></pre></div>

<p>42 pages, zero broken.</p>
<hr>
<h2>The bonus bug: a build that succeeds at doing nothing</h2>
<p>While debugging I hit a second silent failure, unrelated but rhyming.</p>
<p>Running the documented command produced no output whatsoever and exited 0:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">$</span><span style="color:#98C379"> node</span><span style="color:#98C379"> blog/scripts/build.js</span></span>
<span class="line"><span style="color:#61AFEF">$</span><span style="color:#98C379"> echo</span><span style="color:#E5C07B"> $?</span></span>
<span class="line"><span style="color:#61AFEF">0</span></span></code></pre></div>

<p>No posts generated, no error, no log lines. The culprit is the standard ES-module "am I the entry point?" idiom at the bottom of the script:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> (</span><span style="color:#C678DD">import</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">meta</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">url</span><span style="color:#56B6C2"> ===</span><span style="color:#98C379"> `file://</span><span style="color:#C678DD">${</span><span style="color:#E5C07B">process</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">argv</span><span style="color:#ABB2BF">[</span><span style="color:#D19A66">1</span><span style="color:#ABB2BF">]</span><span style="color:#C678DD">}</span><span style="color:#98C379">`</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#61AFEF">    build</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>This compares a <strong>URL</strong> against a <strong>filesystem path</strong>. They are identical only when the path contains no characters requiring percent-encoding. My checkout happens to live in a directory with a space in its name, so:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>import.meta.url      → file:///Users/.../Jeffrey's%20Projects/.../build.js</span></span>
<span class="line"><span>`file://${argv[1]}`  → file:///Users/.../Jeffrey's Projects/.../build.js</span></span></code></pre></div>

<p><code>%20</code> versus a literal space. The comparison fails, <code>build()</code> is never called, the script exits cleanly having done nothing at all.</p>
<p>It never affected production — GitHub Actions checks out to <code>/home/runner/work/...</code>, which has no spaces, so CI has always worked. It only bites locally, and only in a path with a space, which is exactly the kind of environment-dependent bug that gets dismissed as "works on my machine" in reverse.</p>
<p>The correct comparison uses Node's own converter:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">import</span><span style="color:#ABB2BF"> { </span><span style="color:#E06C75">pathToFileURL</span><span style="color:#ABB2BF"> } </span><span style="color:#C678DD">from</span><span style="color:#98C379"> 'node:url'</span><span style="color:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> (</span><span style="color:#C678DD">import</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">meta</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">url</span><span style="color:#56B6C2"> ===</span><span style="color:#61AFEF"> pathToFileURL</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">process</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">argv</span><span style="color:#ABB2BF">[</span><span style="color:#D19A66">1</span><span style="color:#ABB2BF">]).</span><span style="color:#E06C75">href</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#61AFEF">    build</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>As a workaround without touching the file, you can bypass the guard by importing the module and calling its export directly:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">node</span><span style="color:#D19A66"> -e</span><span style="color:#98C379"> "import(require('url').pathToFileURL('blog/scripts/build.js').href).then(m => m.build())"</span></span></code></pre></div>

<hr>
<h2>Lessons</h2>
<p><strong>Exit code 0 is not a test.</strong> Both bugs in this post produced a successful build. One generated nothing; the other generated garbage. Neither threw. If your CI's only assertion is "the build command returned 0," it will cheerfully deploy either. A single grep for <code>[object Object]</code> in the generated output — three lines in a workflow — would have caught this on the very commit that introduced it.</p>
<p><strong>Major bumps of a rendering library need output diffing, not just a green build.</strong> Dependabot did nothing wrong: it announced a major bump and the build passed. What was missing was any check on the <em>artifact</em>. For anything that transforms content, the meaningful regression test compares rendered output before and after, not the exit status.</p>
<p><strong>Verify the verification.</strong> Two of my checks returned reassuring, wrong answers — <code>curl</code> without <code>-L</code> grepping a redirect stub, and <code>grep -c</code> counting lines in minified HTML. When a check tells you everything is fine and your eyes say otherwise, believe your eyes and go audit the check.</p>
<p><strong>Measure the blast radius before diagnosing.</strong> "Is it one post or all of them?" cost one loop to answer and immediately eliminated my Markdown, my frontmatter, and the new post as suspects. Knowing 34 of 39 were affected pointed at shared infrastructure before I had read a single line of the build script.</p>
<p><strong>Delete dead code when you find it.</strong> The <code>text</code> override was already pointless before the upgrade — a no-op wrapper around an identity function. Dead code is not inert. It sat there for months contributing nothing, then a dependency bump turned it into a site-wide outage.</p>
<p>The blog renders again. And there is now a very short item on my list: add <code>grep -o &quot;object Object&quot;</code> as a failing assertion in <code>deploy.yml</code>, so the next time this class of bug appears, the build says so instead of shipping it.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Documentation That Outlived Its Code]]></title>
      <description><![CDATA[Every doc in this repo described a site that no longer existed — a terminal aesthetic replaced a year ago and a colour system reduced to an identity function. Auditing them turned up 60 lines of configuration that no code reads.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/documentation-that-outlived-its-code</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/documentation-that-outlived-its-code</guid>
      <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[meta]]></category>
      <category><![CDATA[documentation]]></category>
      <category><![CDATA[refactoring]]></category>
      <category><![CDATA[portfolio]]></category>
      <category><![CDATA[blog]]></category>
      <category><![CDATA[technical-debt]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/documentation-that-outlived-its-code.png" />
      <content:encoded><![CDATA[<p>Working log for this blog. <strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/object-object-marked-token-api">[object Object]: How One Dependabot Bump Broke 34 of 39 Posts</a>.</p>
<p><code>CLAUDE.md</code> in this repository opened with this:</p>
<blockquote>
<p><strong>Colors</strong>: Terminal green (#55ff55), amber (#ff8855), cyan (#55ffff), magenta (#ff55ff)
<strong>Typography</strong>: Monaco, Menlo, Ubuntu Mono - monospace fonts only
<strong>Background</strong>: Pure black (#000000) with terminal gray text (#c9c9c9)
<strong>No modern UI elements</strong>: No gradients, shadows, or contemporary web design patterns</p>
</blockquote>
<p>The site it describes has not existed since <a href="https://jeffreyjose07.is-a.dev/blog/redesigning-the-portfolio-teal-fonts-and-timelines">episode 026</a>, when I replaced the whole thing with <code>Syne</code> headings, <code>Plus Jakarta Sans</code> body text, and an emerald teal accent. The document sat there for months, confidently instructing anyone who read it — me, or any tool I pointed at the repo — to preserve an aesthetic that had already been deleted.</p>
<p>This is a post about auditing every Markdown file in a repo against the code, and what falls out when you do.</p>
<hr>
<h2>The failure mode: docs are asserted, code is executed</h2>
<p>Code has a forcing function. Write something false in a <code>.ts</code> file and the build fails, the tests go red, the page renders wrong. Documentation has no such mechanism. A <code>.md</code> file that describes a system deleted a year ago will keep describing it forever, and nothing anywhere will complain.</p>
<p>Worse, docs are <em>load-bearing for decisions</em>. Nobody reads a stale file and shrugs; they read it and act. In this repo the stale design section was actively steering work in the wrong direction — every time I asked for a change, the instruction &quot;maintain the strict 90s terminal aesthetic&quot; came along for the ride.</p>
<p>The tell that finally caught it: I asked for a comparison against other developer portfolios, and the answer came back framed around preserving a terminal look. Then I actually opened the site. Light mode. Teal. Geometric sans. Nothing terminal about it.</p>
<h2>What the audit found</h2>
<p>Seven non-post Markdown files. Every one contained something false.</p>
<h3>1. The design system in <code>CLAUDE.md</code> was a year out of date</h3>
<p>Replaced with what the code actually says, sourced from <code>tailwind.config.ts</code> and <code>src/index.css</code> rather than memory:</p>
<div class="code-block-wrap" data-lang="css"><span class="code-lang" aria-hidden="true">css</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">/* src/index.css — the real accent */</span></span>
<span class="line"><span style="color:#C678DD">--primary:      162 75% 38%;</span><span style="color:#7F848E;font-style:italic">   /* light */</span></span>
<span class="line"><span style="color:#C678DD">--primary-glow: 162 75% 52%;</span></span></code></pre></div>

<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">// tailwind.config.ts — the real fonts</span></span>
<span class="line"><span style="color:#E06C75">fontFamily</span><span style="color:#ABB2BF">: {</span></span>
<span class="line"><span style="color:#E06C75">    sans</span><span style="color:#ABB2BF">:    [</span><span style="color:#98C379">'Plus Jakarta Sans'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'sans-serif'</span><span style="color:#ABB2BF">],</span></span>
<span class="line"><span style="color:#E06C75">    heading</span><span style="color:#ABB2BF">: [</span><span style="color:#98C379">'Syne'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'sans-serif'</span><span style="color:#ABB2BF">],</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<h3>2. <code>BLOG.md</code> documented a colour system that does nothing</h3>
<p>An entire chapter explained how technical terms were automatically colour-coded as you wrote — React in yellow, Spring Boot in blue, Docker in green, with a table of mappings and instructions for adding your own terms.</p>
<p>Here is the function that was supposed to be doing it:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">class</span><span style="color:#E5C07B"> SemanticColorizer</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#61AFEF">    colorizeText</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">text</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">        return</span><span style="color:#E06C75"> text</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    }</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>An identity function. It takes text and returns the same text. At some point the colouriser was gutted and the documentation was not.</p>
<p>The instructions for extending it were worse than useless — they told you to add entries to an <code>autoColorTerms</code> key in <code>blog/config.json</code>. That key does not exist in the file. You could follow the documentation exactly and produce no effect whatsoever, with no error to tell you why.</p>
<h3>3. <code>README.md</code> had a CI pipeline that isn&#39;t there</h3>
<p>It described a four-job workflow:</p>
<blockquote>
<ol>
<li><strong>build-blog</strong> … 2. <strong>commit-blog</strong> … 3. <strong>build-portfolio</strong> … 4. <strong>deploy</strong></li>
</ol>
</blockquote>
<p>The actual <code>.github/workflows/deploy.yml</code> has exactly one job, <code>build-and-deploy</code>, that does all four things in sequence. It also claimed Node v18 while <code>package.json</code> requires <code>&gt;=20.19.0</code>, and advertised &quot;semantic highlighting&quot; — the identity function again.</p>
<h3>4. <code>plan.md</code> was a finished roadmap with every box unticked</h3>
<p>Ten <code>- [ ]</code> items for a thumbnail generator that has been shipping images for months. Its design spec also named <code>Outfit</code> and <code>Inter</code> as the site fonts and violet <code>#7c3aed</code> as &quot;Site Primary&quot; — two design systems ago.</p>
<p>I marked it complete and added a header noting which parts are superseded, but deliberately <strong>did not rewrite the body</strong>. It is a record of what was planned at the time. Rewriting it would erase the decision history, which is the only thing a finished roadmap is still good for.</p>
<h3>5. The post template promised magic it can&#39;t do</h3>
<p><code>blog/posts/_template.md</code> — the file you copy to start writing — told you the build would colour your technical terms automatically. Every new post started from a lie about how the system works.</p>
<h2>The part I did not change</h2>
<p>There are 35 blog posts in this repo. Many describe the terminal aesthetic. Several walk through the colour system in detail. All of them are now, strictly speaking, inaccurate about the current site.</p>
<p><strong>I left every one of them alone.</strong></p>
<p>A blog post is a dated record of what was true when it was written. Episode 026 <em>is</em> the redesign — rewriting the posts around it to match today&#39;s CSS would falsify the very history the blog exists to keep. Documentation describes the present tense and must track it; posts describe a moment and must not.</p>
<p>The distinction is worth being explicit about, because &quot;update all the docs&quot; could easily be read as &quot;update all the Markdown,&quot; and those are very different instructions. The test I used: <strong>would changing this file make a past statement untrue?</strong> If yes, it is a record — annotate it, don&#39;t edit it. If no, it is documentation — fix it.</p>
<h2>Then: 60 lines of configuration nobody reads</h2>
<p>Documenting <code>blog/config.json</code> accurately required knowing which keys the build actually uses. Rather than assume, I checked each one:</p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">for</span><span style="color:#E06C75"> k</span><span style="color:#C678DD"> in</span><span style="color:#98C379"> semanticCategories</span><span style="color:#98C379"> contextualRules</span><span style="color:#98C379"> tagColors</span><span style="color:#98C379"> colors</span><span style="color:#98C379"> social</span><span style="color:#98C379"> resumeUrl</span><span style="color:#98C379"> baseUrl</span><span style="color:#ABB2BF">; </span><span style="color:#C678DD">do</span></span>
<span class="line"><span style="color:#61AFEF">  grep</span><span style="color:#D19A66"> -c</span><span style="color:#98C379"> "config\.</span><span style="color:#E06C75">$k</span><span style="color:#98C379">"</span><span style="color:#98C379"> blog/scripts/build.js</span><span style="color:#98C379"> blog/templates/</span><span style="color:#E5C07B">*</span><span style="color:#98C379">.html</span></span>
<span class="line"><span style="color:#C678DD">done</span></span></code></pre></div>

<p>The results split cleanly:</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>References</th>
</tr>
</thead>
<tbody><tr>
<td><code>title</code>, <code>description</code></td>
<td>20, 13</td>
</tr>
<tr>
<td><code>social</code></td>
<td>13</td>
</tr>
<tr>
<td><code>resumeUrl</code></td>
<td>3</td>
</tr>
<tr>
<td><code>tagColors</code></td>
<td>2</td>
</tr>
<tr>
<td><strong><code>colors</code></strong></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><strong><code>semanticCategories</code></strong></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><strong><code>contextualRules</code></strong></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><strong><code>baseUrl</code></strong></td>
<td><strong>0</strong></td>
</tr>
</tbody></table>
<p><code>semanticCategories</code> alone was ~60 lines: six categories, each with a description and a list of twenty-odd patterns mapping words like <code>kubernetes</code>, <code>microservices</code> and <code>memory leak</code> to colour roles. Carefully structured, clearly the product of real thought, and read by absolutely nothing.</p>
<p>Config that <em>looks</em> tunable but has no effect is worse than no config. It is a trap for your future self: something renders wrong, you find a promising-looking pattern list, you edit it, you rebuild, nothing changes, and now you are debugging the wrong file.</p>
<h3>Deleting it safely</h3>
<p>The claim &quot;this config is dead&quot; is falsifiable, so I tested it rather than trusting the grep. Build the site with the original config, hash the output. Build with the trimmed config, hash again:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>local build, ORIGINAL config: 24c885867cd00cd5080264df6c0982a0</span></span>
<span class="line"><span>local build, TRIMMED config:  24c885867cd00cd5080264df6c0982a0</span></span></code></pre></div>

<p>Byte-identical. <code>blog/config.json</code> went from 134 lines to 51, and every remaining key is live.</p>
<p>One wrinkle worth recording: the freshly built <code>sitemap.xml</code> <em>did</em> differ from the committed one — but the same diff appeared with the original config too, and the sorted URL sets hashed identically. It was ordering that differs between my machine and the CI runner, not an effect of the deletion. Without the A/B test I might have blamed my own change and reverted a correct edit.</p>
<h2>What I added to stop the drift</h2>
<p>Fixing the docs does not stop them rotting again. Two things now push back:</p>
<p><strong>A precedence rule, written into <code>CLAUDE.md</code> itself:</strong></p>
<blockquote>
<p>This repo has repeatedly shipped documentation that outlived the code — the terminal aesthetic, the semantic colour system, a &quot;24 posts&quot; roadmap. When a doc and the code disagree, <strong>the code wins</strong>. Check <code>src/index.css</code>, <code>tailwind.config.ts</code>, <code>blog/scripts/build.js</code> and <code>.github/workflows/deploy.yml</code> before acting on any description in a Markdown file.</p>
</blockquote>
<p>A document instructing you not to trust it is a strange artefact, but it is honest about the failure mode, and it names the files that hold ground truth.</p>
<p><strong>An explicit dead-code section</strong>, so the identity-function colouriser is documented as broken rather than quietly omitted. Silence reads as &quot;fine&quot;. Naming it means the next person either fixes it or deletes it, instead of rediscovering it in a year.</p>
<h2>Lessons</h2>
<p><strong>Docs rot silently; code rots loudly.</strong> Nothing in CI checks that <code>CLAUDE.md</code> matches <code>index.css</code>. Until something does, assume every Markdown file is stale until verified against source.</p>
<p><strong>Verify before documenting.</strong> The most useful output of this audit was not the prose — it was the <code>grep -c</code> that proved four config keys were dead. I would never have found that by reading, only by checking.</p>
<p><strong>Distinguish records from documentation.</strong> Blog posts, ADRs, finished roadmaps are dated artefacts: annotate, never rewrite. READMEs, agent instructions, templates are present-tense: they must track reality or actively mislead.</p>
<p><strong>Dead configuration is a liability, not neutral weight.</strong> Sixty lines that look meaningful and do nothing will cost someone an afternoon. Delete it, and prove it was dead with a byte-comparison rather than an argument.</p>
<p><strong>Fix the template.</strong> <code>_template.md</code> is copied into every new post. A false statement there propagates into everything you write next — the highest-leverage line in the repository, and the easiest to forget.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Proving Spring Internals With Tests (Not Folklore)]]></title>
      <description><![CDATA[Series hub: a Java 21 / Spring Boot 4.1 lab where broken and fixed Spring designs are proven with H2 tests. Deep dives for proxies, ThreadLocals, lifecycle, and HikariCP.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[java]]></category>
      <category><![CDATA[testing]]></category>
      <category><![CDATA[transactions]]></category>
      <category><![CDATA[interview-prep]]></category>
      <category><![CDATA[architecture]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/proving-spring-internals-with-tests.png" />
      <content:encoded><![CDATA[<p>I stopped trusting slide-deck explanations of Spring proxies, ThreadLocals, and HikariCP. So I built a lab where every trap has a <strong>broken</strong> path, a <strong>fixed</strong> path, and assertions that force the JVM to show the bug.</p>
<p><strong>Repo:</strong> <a href="https://github.com/jeffreyjose07/spring-validation-lab">jeffreyjose07/spring-validation-lab</a><br><strong>Stack:</strong> Java 21, Spring Boot 4.1, Gradle 9.3, H2, JUnit  </p>
<p>This post is the <strong>series index</strong>. The short overview was not enough — the detailed write-ups live in the posts below.</p>
<h2>Deep dives</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Post</th>
<th>What we proved</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><a href="https://jeffreyjose07.is-a.dev/blog/spring-proxies-and-self-invocation">Proxies and self-invocation</a></td>
<td><code>this.method()</code> skips the proxy; <code>REQUIRES_NEW</code> is dead text; separate bean leaves the independent row after outer rollback</td>
</tr>
<tr>
<td>2</td>
<td><a href="https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async">ThreadLocals and <code>@Async</code></a></td>
<td>Naive executor loses <strong>Authentication</strong> (context object can still be non-null); explicit args and <code>DelegatingSecurityContextRunnable</code> fix it</td>
</tr>
<tr>
<td>3</td>
<td><a href="https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas">Bean lifecycle and mini-umbrellas</a></td>
<td><code>@PostConstruct</code> before proxy; constructor cycles fail to boot; Spring Data invents mini-TXs without an outer umbrella</td>
</tr>
<tr>
<td>4</td>
<td><a href="https://jeffreyjose07.is-a.dev/blog/spring-hikari-hogging-and-optimistic-locking">HikariCP, hogging, optimistic locking</a></td>
<td><code>REQUIRES_NEW</code> vs pool of 10; HTTP inside TX after first SQL; <code>@Version</code> under 20 concurrent buyers</td>
</tr>
</tbody></table>
<p>Teaching notes in the repo mirror the same path under <a href="https://github.com/jeffreyjose07/spring-validation-lab/tree/main/docs"><code>docs/</code></a>. Folklore corrections live in <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/ground-truth.md"><code>docs/ground-truth.md</code></a>.</p>
<h2>The loop</h2>
<ol>
<li>State the production trap.  </li>
<li>Write the <strong>broken</strong> service on purpose.  </li>
<li>Assert the failure (wrong rows, NPE, timeouts, pool fully checked out).  </li>
<li>Write the <strong>fixed</strong> design.  </li>
<li>Assert the success.  </li>
<li>Write down every place Boot 4.1 disagreed with the blog-post version of Spring.</li>
</ol>
<h2>How to try it</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">git</span><span style="color:#98C379"> clone</span><span style="color:#98C379"> https://github.com/jeffreyjose07/spring-validation-lab.git</span></span>
<span class="line"><span style="color:#56B6C2">cd</span><span style="color:#98C379"> spring-validation-lab</span></span>
<span class="line"><span style="color:#C678DD">export</span><span style="color:#E06C75"> JAVA_HOME</span><span style="color:#56B6C2">=</span><span style="color:#ABB2BF">$(</span><span style="color:#61AFEF">/usr/libexec/java_home</span><span style="color:#D19A66"> -v</span><span style="color:#D19A66"> 21</span><span style="color:#ABB2BF">)   </span><span style="color:#7F848E;font-style:italic"># macOS</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*SelfInvocationProxyTest'</span></span></code></pre></div>

<p>Open a single test in IntelliJ and watch the Run console for <code>LabLog</code> banners like:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>========== [PHASE1-PROXY] BROKEN self-invocation ==========</span></span>
<span class="line"><span>  → Calling this.saveIndependently(...) — REQUIRES_NEW sticky note will NOT be read</span></span></code></pre></div>

<h2>What I am not doing next (yet)</h2>
<p>I am deliberately not racing into Kafka outbox / saga posts until the Phase 1–2 proofs feel boring. The point of this lab is <strong>competence under the hood</strong>, not checklist coverage.</p>
<p>If you interview for Spring-heavy backend roles: stop only reading about proxies. Make one fail on purpose. Keep the green and red tests. Write down every place the framework surprised you.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Spring Proxies and Self-Invocation: What We Proved]]></title>
      <description><![CDATA[Phase 1 of the spring-validation-lab: @Transactional is a sticky note read by a proxy. Self-invocation makes REQUIRES_NEW dead text. Broken and fixed designs, asserted with H2.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/spring-proxies-and-self-invocation</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/spring-proxies-and-self-invocation</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[java]]></category>
      <category><![CDATA[transactions]]></category>
      <category><![CDATA[aop]]></category>
      <category><![CDATA[interview-prep]]></category>
      <category><![CDATA[testing]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/spring-proxies-and-self-invocation.png" />
      <content:encoded><![CDATA[<p>This is part of a series from <a href="https://github.com/jeffreyjose07/spring-validation-lab">spring-validation-lab</a> — Java 21, Spring Boot 4.1, Gradle, H2. Series index: <a href="https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests">Proving Spring Internals With Tests</a>.</p>
<p><strong>This post:</strong> proxies and <code>@Transactional</code> self-invocation.<br><strong>Next:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async">ThreadLocals and <code>@Async</code></a>.</p>
<p>I used to say “self-invocation bypasses the proxy” in interviews like it was a slogan. That is not the same as being able to force a rollback and point at a row count of zero. This post is the full proof we built for that trap — theory, broken code, fixed code, assertions, and the ground-truth surprise that still trips people in follow-ups.</p>
<h2>The production trap</h2>
<p>You need an audit (or ledger) write that <strong>must survive</strong> even if the outer business method fails and rolls back. The textbook move is <code>@Transactional(propagation = Propagation.REQUIRES_NEW)</code> on an “independent” method.</p>
<p>Someone writes:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> processBatchThenFail</span><span style="color:#E06C75">(</span><span style="color:#ABB2BF">...</span><span style="color:#E06C75">) {</span></span>
<span class="line"><span style="color:#E5C07B">    this</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">saveIndependently</span><span style="color:#ABB2BF">(accountId, amount);</span><span style="color:#7F848E;font-style:italic"> // looks fine</span></span>
<span class="line"><span style="color:#C678DD">    throw</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> IllegalStateException</span><span style="color:#E06C75">(</span><span style="color:#98C379">"boom"</span><span style="color:#E06C75">)</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E06C75">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span><span style="color:#E06C75">(</span><span style="color:#D19A66">propagation</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> Propagation</span><span style="color:#ABB2BF">.</span><span style="color:#E5C07B">REQUIRES_NEW</span><span style="color:#E06C75">)</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> saveIndependently</span><span style="color:#E06C75">(</span><span style="color:#ABB2BF">...</span><span style="color:#E06C75">) {</span></span>
<span class="line"><span style="color:#E5C07B">    repository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">save</span><span style="color:#ABB2BF">(...);</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<p>In production review this often ships. In a real outage it looks like “REQUIRES_NEW is broken.” It is not. The call never crossed a Spring AOP proxy, so the sticky note was never read.</p>
<h2>Theory: sticky notes, not JVM keywords</h2>
<p><code>@Transactional</code> is not bytecode magic by default. Spring wraps your bean in a <strong>CGLIB (or JDK) proxy</strong> when transaction AOP is active. External callers receive the <strong>proxy</strong> from the <code>ApplicationContext</code>.</p>
<p>Call path that works:</p>
<ol>
<li>Caller → <strong>proxy</strong></li>
<li>Proxy sees <code>@Transactional</code> → begins / suspends / joins a transaction</li>
<li>Proxy invokes your real method on the <strong>target</strong></li>
<li>Proxy commits or rolls back on the way out</li>
</ol>
<p>Call path that fails silently:</p>
<ol>
<li>You are already inside the target method</li>
<li>You call <code>this.saveIndependently(...)</code></li>
<li><code>this</code> is the <strong>raw target object</strong>, not the proxy</li>
<li>No interceptor runs → <code>REQUIRES_NEW</code> is dead text</li>
<li>The “independent” save joins (or shares) the outer transaction</li>
<li>Outer throw → everything rolls back together</li>
</ol>
<p><code>REQUIRES_NEW</code> means: suspend the outer TX and open a new one. That only happens if the <strong>proxy</strong> sees the call.</p>
<h2>Ground truth you should say in interviews</h2>
<p><strong>Inside a method body, <code>this</code> is never the proxy</strong> — even when a transaction is already active because the proxy opened it.</p>
<p>People say: “If <code>TransactionSynchronizationManager.isActualTransactionActive()</code> is true, then <code>AopUtils.isAopProxy(this)</code> must be true.” We measured the opposite.</p>
<p>Proof of interception is one of:</p>
<ul>
<li><code>TransactionSynchronizationManager.isActualTransactionActive()</code> after entering via a proxied call</li>
<li>Checking the bean <strong>injected from the context</strong> (<code>AopUtils.isAopProxy(injectedBean) == true</code>)</li>
<li>Observing commit/rollback behavior under controlled failure (best)</li>
</ul>
<p>Never trust <code>AopUtils.isAopProxy(this)</code> inside the class as proof that annotations are “on.”</p>
<h2>Broken design (lab)</h2>
<p>Class: <code>BrokenSelfInvocationLedgerService</code></p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> processBatchThenFail</span><span style="color:#E06C75">(</span><span style="color:#E5C07B">String</span><span style="color:#E06C75"> accountId</span><span style="color:#ABB2BF">,</span><span style="color:#C678DD"> long</span><span style="color:#E06C75"> amount) {</span></span>
<span class="line"><span style="color:#E5C07B">    this</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">saveIndependently</span><span style="color:#ABB2BF">(accountId, amount);</span></span>
<span class="line"><span style="color:#C678DD">    throw</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> IllegalStateException</span><span style="color:#E06C75">(</span><span style="color:#98C379">"Simulated business failure after independent save"</span><span style="color:#E06C75">)</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E06C75">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span><span style="color:#E06C75">(</span><span style="color:#D19A66">propagation</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> Propagation</span><span style="color:#ABB2BF">.</span><span style="color:#E5C07B">REQUIRES_NEW</span><span style="color:#E06C75">)</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> saveIndependently</span><span style="color:#E06C75">(</span><span style="color:#E5C07B">String</span><span style="color:#E06C75"> accountId</span><span style="color:#ABB2BF">,</span><span style="color:#C678DD"> long</span><span style="color:#E06C75"> amount) {</span></span>
<span class="line"><span style="color:#E5C07B">    repository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">save</span><span style="color:#ABB2BF">(</span><span style="color:#C678DD">new</span><span style="color:#61AFEF"> LedgerEntry</span><span style="color:#ABB2BF">(accountId, amount));</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<p>Console banners (via <code>LabLog</code>) make the intent loud while the test runs:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>========== [PHASE1-PROXY] BROKEN self-invocation ==========</span></span>
<span class="line"><span>  → Calling this.saveIndependently(...) — REQUIRES_NEW sticky note will NOT be read</span></span>
<span class="line"><span>  ✗ Throwing after 'independent' save — expect BOTH to roll back...</span></span></code></pre></div>

<h3>How we proved it</h3>
<p>Test: <code>SelfInvocationProxyTest#selfInvocationDoesNotCommitIndependently</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*SelfInvocationProxyTest.selfInvocation*'</span></span></code></pre></div>

<p>Assert:</p>
<ol>
<li>Method throws <code>IllegalStateException</code> (simulated business failure).</li>
<li><code>repository.countByAccountId(&quot;acct-broken&quot;)</code> is <strong>0</strong>.</li>
</ol>
<p>If <code>REQUIRES_NEW</code> had fired, the independent row would already be committed before the outer throw. Zero rows means there was only one transaction, and it rolled back.</p>
<p>That is the interview-grade proof: not a stack trace story, a <strong>persisted count</strong>.</p>
<h2>Fixed design (lab)</h2>
<p>Split the independent write onto another Spring bean so the call crosses a proxy boundary.</p>
<ul>
<li><code>FixedLedgerService</code> — outer <code>@Transactional</code>, orchestrates, then throws</li>
<li><code>IndependentLedgerWriteService</code> — <code>@Transactional(REQUIRES_NEW)</code> + repository save</li>
</ul>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> processBatchThenFail</span><span style="color:#E06C75">(</span><span style="color:#E5C07B">String</span><span style="color:#E06C75"> accountId</span><span style="color:#ABB2BF">,</span><span style="color:#C678DD"> long</span><span style="color:#E06C75"> amount) {</span></span>
<span class="line"><span style="color:#E5C07B">    independentWriteService</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">saveIndependently</span><span style="color:#ABB2BF">(accountId, amount);</span></span>
<span class="line"><span style="color:#C678DD">    throw</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> IllegalStateException</span><span style="color:#E06C75">(</span><span style="color:#98C379">"Simulated business failure after independent save"</span><span style="color:#E06C75">)</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<p>Same failure throw. Different call graph.</p>
<h3>How we proved the fix</h3>
<p>Test: <code>SelfInvocationProxyTest#separateBeanRequiresNewSurvivesOuterRollback</code></p>
<p>Assert:</p>
<ol>
<li>Outer method still throws.</li>
<li><code>repository.countByAccountId(&quot;acct-fixed&quot;)</code> is <strong>1</strong>.</li>
</ol>
<p>The independent TX committed before the outer rollback. That is the behavioral definition of <code>REQUIRES_NEW</code> working.</p>
<h2>Why “inject self” is a weaker fix</h2>
<p>You can inject your own bean and call <code>self.saveIndependently(...)</code>. It works mechanically. It also keeps two concerns in one class and confuses readers (“why is this calling itself through a field?”).</p>
<p>Prefer:</p>
<ul>
<li>A small dedicated write service (what the lab does), or</li>
<li>An application event / outbox later when the independence is really a domain boundary</li>
</ul>
<p>For interviews: know that self-injection works; prefer a separate bean as the clean answer.</p>
<h2>What this teaches beyond the slogan</h2>
<ol>
<li><strong>Annotations are metadata.</strong> Without an interceptor (proxy, AspectJ weave, etc.), they do nothing.</li>
<li><strong>Propagation only applies at proxy boundaries.</strong> <code>REQUIRED</code>, <code>REQUIRES_NEW</code>, <code>NESTED</code> — same rule.</li>
<li><strong>Silent failure is the danger.</strong> Self-invocation does not throw “annotation ignored.” It just behaves like a normal method call.</li>
<li><strong>Prove with data.</strong> Rollback + row count beats hand-waving about AOP.</li>
</ol>
<h2>Interview answers that hold under follow-up</h2>
<p><strong>Q: Why did my REQUIRES_NEW audit roll back with the payment?</strong><br>A: Likely called via <code>this</code>. The proxy never saw the call. Split the write onto another bean and re-test with an intentional outer failure; the audit row should remain.</p>
<p><strong>Q: How do you know you are talking to a proxy?</strong><br>A: The object from the context is a proxy when AOP applies. Inside the method, <code>this</code> is still the target. Use TX activity / commit behavior, not <code>isAopProxy(this)</code>.</p>
<p><strong>Q: Does <code>@EnableAspectJAutoProxy(exposeProxy = true)</code> + <code>AopContext.currentProxy()</code> count?</strong><br>A: Yes, it can force re-entry through the proxy. It is a smell for most codebases — couples you to AOP internals. Prefer another bean.</p>
<h2>How to run just this proof</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">git</span><span style="color:#98C379"> clone</span><span style="color:#98C379"> https://github.com/jeffreyjose07/spring-validation-lab.git</span></span>
<span class="line"><span style="color:#56B6C2">cd</span><span style="color:#98C379"> spring-validation-lab</span></span>
<span class="line"><span style="color:#C678DD">export</span><span style="color:#E06C75"> JAVA_HOME</span><span style="color:#56B6C2">=</span><span style="color:#ABB2BF">$(</span><span style="color:#61AFEF">/usr/libexec/java_home</span><span style="color:#D19A66"> -v</span><span style="color:#D19A66"> 21</span><span style="color:#ABB2BF">)   </span><span style="color:#7F848E;font-style:italic"># macOS</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*SelfInvocationProxyTest'</span></span></code></pre></div>

<p>Teaching notes: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/01-proxies-and-transactions.md"><code>docs/01-proxies-and-transactions.md</code></a><br>Ground-truth notes: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/ground-truth.md"><code>docs/ground-truth.md</code></a></p>
<h2>One-liner</h2>
<blockquote>
<p>Annotations like <code>@Transactional</code> only apply when the call crosses a Spring proxy. Self-invocation is a silent no-op for AOP.</p>
</blockquote>
<p>Next: <a href="https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async">Spring ThreadLocals and <code>@Async</code></a> — where SecurityContext goes when work jumps threads, and why “null SecurityContext” is usually the wrong diagnosis.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Spring ThreadLocals and @Async: What We Proved]]></title>
      <description><![CDATA[Phase 1 continued: SecurityContext lives in a ThreadLocal by default. Naive @Async loses Authentication (not the context object). Explicit args and DelegatingSecurityContextRunnable fixes.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[java]]></category>
      <category><![CDATA[security]]></category>
      <category><![CDATA[async]]></category>
      <category><![CDATA[interview-prep]]></category>
      <category><![CDATA[testing]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/spring-threadlocals-and-async.png" />
      <content:encoded><![CDATA[<p>Series from <a href="https://github.com/jeffreyjose07/spring-validation-lab">spring-validation-lab</a>. Index: <a href="https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests">Proving Spring Internals With Tests</a>.</p>
<p><strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-proxies-and-self-invocation">Proxies and self-invocation</a><br><strong>This post:</strong> ThreadLocals, <code>@Async</code>, and SecurityContext<br><strong>Next:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas">Bean lifecycle and mini-umbrellas</a></p>
<p>The classic interview line is: “<code>@Async</code> loses the SecurityContext because ThreadLocal does not cross threads.” That sentence is directionally right and operationally sloppy. On Spring Boot 4.1 we had to be precise — and we had to build a <strong>naive executor on purpose</strong>, because the default path did not fail the way folklore promised.</p>
<h2>The production trap</h2>
<p>Request thread authenticates the user. Controller calls a service that fires <code>@Async</code> audit logging. The worker thread calls:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">SecurityContextHolder</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">getContext</span><span style="color:#ABB2BF">().</span><span style="color:#61AFEF">getAuthentication</span><span style="color:#ABB2BF">().</span><span style="color:#61AFEF">getName</span><span style="color:#ABB2BF">();</span></span></code></pre></div>

<p>Boom: NPE. Ops blames “null SecurityContext.” Debug session shows something more annoying: <code>getContext()</code> returned a <strong>non-null empty context</strong>. What was null was <strong><code>Authentication</code></strong>.</p>
<p>Same class of bug shows up with:</p>
<ul>
<li>raw <code>Executor</code> / <code>CompletableFuture.runAsync</code></li>
<li><code>parallelStream()</code> after reading ThreadLocal state</li>
<li>custom thread pools without a <code>TaskDecorator</code></li>
</ul>
<h2>Theory: lockers, not magic globals</h2>
<p><code>SecurityContextHolder</code> uses a <strong>strategy</strong>. Default strategy is <strong>ThreadLocal</strong> (MODE_THREADLOCAL). Think of it as a labeled locker on the current thread:</p>
<ol>
<li>Filter / interceptor puts <code>Authentication</code> into the context on the Tomcat (or WebFlux — different story) thread.</li>
<li>Your service reads it on that same thread — fine.</li>
<li><code>@Async</code> schedules work on a <strong>pool thread</strong> — different locker.</li>
<li>Unless something <strong>copied</strong> the context onto the worker, the worker’s locker is empty.</li>
</ol>
<p>Fixes fall into two buckets:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Idea</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Explicit</strong></td>
<td>Pass <code>username</code> / user id / DTO as a method argument. No ThreadLocal required on the worker.</td>
</tr>
<tr>
<td><strong>Framework</strong></td>
<td><code>TaskDecorator</code> + <code>DelegatingSecurityContextRunnable</code> (or Spring Security’s async support) copies context onto the worker and <strong>clears</strong> it afterward so pool threads do not leak identity.</td>
</tr>
</tbody></table>
<p>Explicit is clearer for audits and batch jobs. Framework propagation is convenient when many layers already assume <code>SecurityContextHolder</code>.</p>
<h2>Ground truth: folklore vs Boot 4.1</h2>
<h3>1. Default <code>@Async</code> may already propagate</h3>
<p><strong>Folklore:</strong> Any <code>@Async</code> method always sees a missing security context.</p>
<p><strong>Lab:</strong> Plain <code>@Async</code> on our Boot 4.1 / modern Spring Security stack did <strong>not</strong> reliably demonstrate the failure. Framework wiring already helped in some paths.</p>
<p>So the lab defines <code>naiveAsyncExecutor</code> — a <code>ThreadPoolTaskExecutor</code> with <strong>no</strong> <code>TaskDecorator</code> — and binds the broken service with <code>@Async(&quot;naiveAsyncExecutor&quot;)</code>. That keeps the ThreadLocal loss demonstrable and honest.</p>
<p><strong>Interview phrasing:</strong> ThreadLocal does not cross threads <em>unless</em> the executor (or Security) copies it. Always verify your executor bean.</p>
<h3>2. Empty context ≠ null context</h3>
<p><code>SecurityContextHolder.getContext()</code> typically returns a context object even when nobody is authenticated. The failure mode is almost always:</p>
<div class="code-block-wrap" data-lang="text"><span class="code-lang" aria-hidden="true">text</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span>context != null</span></span>
<span class="line"><span>authentication == null</span></span>
<span class="line"><span>authentication.getName() → NPE</span></span></code></pre></div>

<p>Say that out loud in interviews. It signals you have actually tripped the bug.</p>
<h2>Broken design (lab)</h2>
<p><code>BrokenAsyncAuditService</code>:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Async</span><span style="color:#E06C75">(</span><span style="color:#98C379">"naiveAsyncExecutor"</span><span style="color:#E06C75">)</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#E5C07B"> CompletableFuture</span><span style="color:#56B6C2">&#x3C;</span><span style="color:#E06C75">String</span><span style="color:#56B6C2">></span><span style="color:#61AFEF"> logAccessBroken</span><span style="color:#E06C75">() {</span></span>
<span class="line"><span style="color:#E5C07B">    Authentication</span><span style="color:#E06C75"> auth </span><span style="color:#56B6C2">=</span><span style="color:#E5C07B"> SecurityContextHolder</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">getContext</span><span style="color:#ABB2BF">().</span><span style="color:#61AFEF">getAuthentication</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#C678DD">    if</span><span style="color:#E06C75"> (auth </span><span style="color:#56B6C2">==</span><span style="color:#D19A66"> null</span><span style="color:#E06C75">) {</span></span>
<span class="line"><span style="color:#C678DD">        return</span><span style="color:#E5C07B"> CompletableFuture</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">failedFuture</span><span style="color:#ABB2BF">(</span></span>
<span class="line"><span style="color:#C678DD">                new</span><span style="color:#61AFEF"> NullPointerException</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">"SecurityContext is null on async thread"</span><span style="color:#ABB2BF">));</span></span>
<span class="line"><span style="color:#E06C75">    }</span></span>
<span class="line"><span style="color:#C678DD">    return</span><span style="color:#E5C07B"> CompletableFuture</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">completedFuture</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">auth</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">getName</span><span style="color:#ABB2BF">());</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<p>(The error message says “SecurityContext” for historical slogan reasons; the check is on <code>Authentication</code>. The test asserts the NPE cause.)</p>
<p><code>AsyncConfig#naiveAsyncExecutor</code> — no decorator, no copy, no clear.</p>
<h3>How we proved it</h3>
<p>Test: <code>SecurityContextAsyncTest#asyncWithoutPropagationLosesSecurityContext</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*SecurityContextAsyncTest.asyncWithout*'</span></span></code></pre></div>

<p>Setup: put <code>UsernamePasswordAuthenticationToken(&quot;jeffrey&quot;, ...)</code> on the test thread.</p>
<p>Assert: <code>join()</code> fails with <code>CompletionException</code> whose cause is <code>NullPointerException</code> (missing auth on worker).</p>
<h2>Fixed design A — explicit argument</h2>
<p><code>ExplicitAsyncAuditService</code> takes <code>String username</code> (or could take a richer DTO). The worker never reads <code>SecurityContextHolder</code> for identity.</p>
<p>Test: <code>explicitArgumentPropagationWorks</code> → result equals <code>&quot;jeffrey&quot;</code>.</p>
<p>This is the design I push hardest in interviews for fire-and-forget audit: <strong>make the boundary obvious</strong>. Arguments are visible in signatures, logs, and tests.</p>
<h2>Fixed design B — framework copy</h2>
<p><code>PropagatingAsyncAuditService</code> uses <code>@Async(&quot;securityContextPropagatingExecutor&quot;)</code>.</p>
<p>The executor sets:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">executor</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">setTaskDecorator</span><span style="color:#ABB2BF">(runnable </span><span style="color:#C678DD">-></span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#E5C07B">    SecurityContext</span><span style="color:#E06C75"> context</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> SecurityContextHolder</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">getContext</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#C678DD">    return</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> DelegatingSecurityContextRunnable</span><span style="color:#ABB2BF">(runnable, context);</span></span>
<span class="line"><span style="color:#ABB2BF">});</span></span></code></pre></div>

<p><code>DelegatingSecurityContextRunnable</code> installs the captured context on the worker thread before <code>run()</code>, then restores/clears appropriately so the next task on that pool thread does not inherit yesterday’s principal.</p>
<p>Test: <code>frameworkPropagationWorks</code> → worker still sees <code>&quot;jeffrey&quot;</code> with no explicit arg.</p>
<h3>Why clear matters</h3>
<p>Without cleanup, pool threads keep ThreadLocals. User A’s request finishes; thread returns to pool; User B’s task briefly sees User A. That is a security incident class, not a style nit.</p>
<h2>What we learned that slides skip</h2>
<ol>
<li><strong>ThreadLocal is a concurrency primitive</strong>, not a request-scoped DI feature. Crossing threads is always your problem unless the framework documents a copy.</li>
<li><strong><code>@Async</code> is not one behavior.</strong> It depends on which <code>Executor</code> bean is wired and whether Security auto-config decorated it.</li>
<li><strong>Prefer explicit data for domain events.</strong> Propagation is for when you must keep calling <code>SecurityContextHolder</code> deep in shared libraries.</li>
<li><strong>Prove both failure and fix.</strong> One green “async works” test teaches nothing about the failure mode.</li>
</ol>
<h2>Interview answers that hold</h2>
<p><strong>Q: Why did async audit log as anonymous / NPE?</strong><br>A: Worker thread did not receive Authentication. Context object can still be non-null. Check the executor for a Security <code>TaskDecorator</code>, or pass the principal explicitly.</p>
<p><strong>Q: Is <code>SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)</code> the fix?</strong><br>A: It helps only for child threads created in ways that inherit ThreadLocals. It is easy to misuse with pools (inheritance at pool-thread creation time ≠ per-task copy). Prefer decorator or explicit args for <code>@Async</code> pools.</p>
<p><strong>Q: What about WebFlux / reactive?</strong><br>A: Different model (Reactor Context), not ThreadLocal-by-default the same way. Do not answer a servlet <code>@Async</code> question with Reactor slogans.</p>
<h2>How to run just this proof</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*SecurityContextAsyncTest'</span></span></code></pre></div>

<p>Docs: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/02-threadlocals-and-async.md"><code>docs/02-threadlocals-and-async.md</code></a></p>
<h2>One-liner</h2>
<blockquote>
<p>ThreadLocal state does not cross thread boundaries. <code>@Async</code>, <code>parallelStream</code>, and raw thread pools leave Authentication behind unless you copy it or pass data explicitly — and verify your executor, because modern defaults may already propagate.</p>
</blockquote>
<p>Next: <a href="https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas">Bean lifecycle, circular dependencies, and repository mini-umbrellas</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Spring Bean Lifecycle, Circular Deps, and Mini-Umbrellas]]></title>
      <description><![CDATA[Phase 1 wrap: @PostConstruct runs before the AOP proxy; constructor cycles fail to boot; Spring Data invents mini-transactions when you have no outer @Transactional.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[java]]></category>
      <category><![CDATA[transactions]]></category>
      <category><![CDATA[lifecycle]]></category>
      <category><![CDATA[interview-prep]]></category>
      <category><![CDATA[testing]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/spring-bean-lifecycle-and-mini-umbrellas.png" />
      <content:encoded><![CDATA[<p>Series from <a href="https://github.com/jeffreyjose07/spring-validation-lab">spring-validation-lab</a>. Index: <a href="https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests">Proving Spring Internals With Tests</a>.</p>
<p><strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-threadlocals-and-async">ThreadLocals and <code>@Async</code></a><br><strong>This post:</strong> lifecycle, circular dependencies, repository mini-transactions<br><strong>Next:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-hikari-hogging-and-optimistic-locking">HikariCP, connection hogging, optimistic locking</a></p>
<p>Phase 1 is not only proxies and ThreadLocals. Three related traps show up in every senior Spring loop: startup seeding that “has <code>@Transactional</code> but doesn’t,” circular constructor graphs that refuse to boot, and services that throw after two <code>save()</code> calls only to discover <strong>both rows persisted</strong> because there was never an outer umbrella.</p>
<p>We proved all three with H2.</p>
<h2>Trap 1 — <code>@PostConstruct</code> before the proxy</h2>
<h3>Theory (lifecycle order, simplified)</h3>
<ol>
<li>Instantiation (<code>new</code>)</li>
<li>Injection (constructors / setters / fields)</li>
<li>Initialization (<code>@PostConstruct</code>, <code>InitializingBean</code>, …)</li>
<li><strong>AOP proxy creation</strong> / publish bean to the context for clients</li>
</ol>
<p>So when <code>@PostConstruct</code> runs, you are still on the <strong>raw target</strong>. A <code>@Transactional</code> sticky note on that method is unread. No Spring transaction umbrella opens from that annotation.</p>
<p>People then observe: “but my seed rows are in the DB!” Correct — Spring Data repository methods are themselves <code>@Transactional(REQUIRED)</code>. With no outer TX, each <code>save()</code> opens a <strong>mini-transaction</strong>, commits, and returns. Your PostConstruct “transaction” failed; the repository’s did not.</p>
<h3>Broken-ish design we measured</h3>
<p><code>PostConstructSeeder</code>:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">PostConstruct</span></span>
<span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span><span style="color:#7F848E;font-style:italic"> // ignored — no proxy yet</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> seedOnStartup</span><span style="color:#E06C75">() {</span></span>
<span class="line"><span style="color:#E06C75">    observedAsProxyDuringPostConstruct </span><span style="color:#56B6C2">=</span><span style="color:#E5C07B"> AopUtils</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">isAopProxy</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">this</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#E06C75">    transactionActiveDuringPostConstruct </span><span style="color:#56B6C2">=</span></span>
<span class="line"><span style="color:#E5C07B">            TransactionSynchronizationManager</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">isActualTransactionActive</span><span style="color:#ABB2BF">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E5C07B">    repository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">save</span><span style="color:#ABB2BF">(</span><span style="color:#C678DD">new</span><span style="color:#61AFEF"> SeedConfig</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">"MAX_USERS"</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">"5000"</span><span style="color:#ABB2BF">));</span></span>
<span class="line"><span style="color:#E5C07B">    repository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">save</span><span style="color:#ABB2BF">(</span><span style="color:#C678DD">new</span><span style="color:#61AFEF"> SeedConfig</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">"THEME"</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">"DARK"</span><span style="color:#ABB2BF">));</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<h3>How we proved it</h3>
<p>Test: <code>PostConstructBeforeProxyTest</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*PostConstructBeforeProxyTest'</span></span></code></pre></div>

<p>Assert during PostConstruct observations:</p>
<table>
<thead>
<tr>
<th>Observation</th>
<th>Expected</th>
</tr>
</thead>
<tbody><tr>
<td><code>postConstructRan</code></td>
<td>true</td>
</tr>
<tr>
<td><code>isAopProxy(this)</code></td>
<td><strong>false</strong></td>
</tr>
<tr>
<td><code>isActualTransactionActive()</code></td>
<td><strong>false</strong></td>
</tr>
<tr>
<td>rows for <code>MAX_USERS</code> / <code>THEME</code></td>
<td><strong>both present</strong> (mini-TXs)</td>
</tr>
</tbody></table>
<p>Second test in the same class: after full context build, the <strong>injected</strong> <code>PostConstructSeeder</code> <strong>is</strong> a proxy (<code>AopUtils.isAopProxy(postConstructSeeder) == true</code>). An <code>ApplicationRunner</code> (<code>AfterContextReadySeeder</code>) with <code>@Transactional</code> sees <code>txActive=true</code> and can seed under a real umbrella.</p>
<h3>What to do instead</h3>
<p>For transactional startup work, wait until the context is ready:</p>
<ul>
<li><code>ApplicationRunner</code> / <code>CommandLineRunner</code></li>
<li><code>ContextRefreshedEvent</code> listener</li>
<li>explicit <code>@Transactional</code> service called from those hooks (so the call crosses a proxy)</li>
</ul>
<p>Do not put critical multi-step transactional seeding on <code>@PostConstruct</code>.</p>
<h2>Trap 2 — constructor circular dependencies</h2>
<h3>Theory</h3>
<p>Constructor injection A ↔ B means neither object can finish construction without the other. There is no half-built instance to stash in the third-level cache the way field/setter injection historically allowed.</p>
<p>Spring Boot <strong>disables circular references by default</strong> since 2.6. Field injection cycles also fail unless you re-enable the escape hatch. The principled fix is almost always a <strong>third orchestrator</strong> or <code>@Lazy</code> on one side — not “turn circular refs back on and move on.”</p>
<h3>How we proved the crash</h3>
<p><code>ConstructorCircularDependencyTest</code> uses <code>ApplicationContextRunner</code> scanning profile-gated circular beans.</p>
<ol>
<li><strong>Constructor cycle</strong> (<code>circular-constructor</code> profile) → context <strong>hasFailed</strong>, cause mentions circular / <code>BeanCurrentlyInCreationException</code>.</li>
<li><strong>Field cycle</strong> (<code>circular-field</code>) → also fails by default on modern Boot.</li>
<li><strong>Runner escape hatch:</strong> <code>.withAllowCircularReferences(true)</code> boots the field cycle.</li>
<li><strong>Principal fix:</strong> <code>@Lazy</code> on one constructor parameter boots cleanly without enabling circular refs globally.</li>
</ol>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*ConstructorCircularDependencyTest'</span></span></code></pre></div>

<h3>Ground truth: the property people mis-cite</h3>
<p><strong>Folklore:</strong> set <code>spring.main.allow-circular-references=true</code> and every test harness suddenly allows cycles.</p>
<p><strong>Lab:</strong> that property applies to <strong><code>SpringApplication</code></strong>. A raw <code>ApplicationContextRunner</code> <strong>ignores</strong> it. Use:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">runner</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">withAllowCircularReferences</span><span style="color:#ABB2BF">(</span><span style="color:#D19A66">true</span><span style="color:#ABB2BF">)</span></span></code></pre></div>

<p>or fix the graph with <code>@Lazy</code> / redesign.</p>
<p>If an interviewer asks “how do you allow circular references in tests?”, distinguishing <code>SpringApplication</code> vs <code>ApplicationContextRunner</code> is a strong senior signal.</p>
<h2>Trap 3 — repository mini-umbrellas</h2>
<h3>Theory</h3>
<p><code>SimpleJpaRepository</code> methods are <code>@Transactional</code> with <code>Propagation.REQUIRED</code>:</p>
<ul>
<li>Outer TX present → join it</li>
<li>No outer TX → open a short-lived transaction, commit on method exit</li>
</ul>
<p>So “my service method is not transactional” does <strong>not</strong> mean “nothing commits until I say so.” It means each repository call may commit immediately.</p>
<p>That is catastrophic for multi-step writes that must be atomic: save A, save B, throw → both A and B already durable.</p>
<h3>Broken design (lab)</h3>
<p><code>NonTransactionalNoteService</code> — no class/method <code>@Transactional</code>, two <code>save()</code>s, then throw.</p>
<h3>Fixed / contrasting design</h3>
<p><code>TransactionalNoteService</code> — outer <code>@Transactional</code>, same two saves, then throw → both roll back.</p>
<h3>How we proved it</h3>
<p>Test: <code>RepositoryMiniUmbrellaTest</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*RepositoryMiniUmbrellaTest'</span></span></code></pre></div>

<table>
<thead>
<tr>
<th>Path</th>
<th>After exception</th>
</tr>
</thead>
<tbody><tr>
<td>No outer TX</td>
<td><code>note-a</code> and <code>note-b</code> <strong>both count = 1</strong></td>
</tr>
<tr>
<td>Outer <code>@Transactional</code></td>
<td><code>note-c</code> and <code>note-d</code> <strong>both count = 0</strong></td>
</tr>
</tbody></table>
<p>Same throw. Different atomicity. The difference is the umbrella.</p>
<h2>How the three traps connect</h2>
<p>All three are about <strong>when</strong> Spring’s transactional interceptors exist and <strong>who</strong> opens the transaction:</p>
<table>
<thead>
<tr>
<th>Moment / call</th>
<th>Who reads <code>@Transactional</code>?</th>
</tr>
</thead>
<tbody><tr>
<td><code>@PostConstruct</code> on your bean</td>
<td>Nobody (no proxy yet)</td>
</tr>
<tr>
<td><code>repository.save</code> with no outer TX</td>
<td>Repository proxy → mini-TX</td>
</tr>
<tr>
<td><code>repository.save</code> under your <code>@Transactional</code> service</td>
<td>Your proxy opened umbrella; repo joins</td>
</tr>
<tr>
<td>Constructor A↔B</td>
<td>Context never finishes — annotations irrelevant</td>
</tr>
</tbody></table>
<p>Once you see that map, “transactional seeding on PostConstruct” and “partial commits without outer TX” stop feeling like unrelated trivia.</p>
<h2>Interview answers that hold</h2>
<p><strong>Q: Can I use <code>@Transactional</code> on <code>@PostConstruct</code>?</strong><br>A: The annotation on that method is ignored; proxy does not exist yet. Rows may still persist via repository mini-transactions. Use a runner after context refresh.</p>
<p><strong>Q: Why did half my writes survive an exception?</strong><br>A: No outer transaction. Each Spring Data <code>save</code> likely committed independently. Wrap the unit of work in <code>@Transactional</code> on a proxied service method.</p>
<p><strong>Q: Constructor circular dependency — enable the flag?</strong><br>A: Prefer redesign or <code>@Lazy</code>. Know Boot defaults to deny cycles. Know <code>spring.main.allow-circular-references</code> is a <code>SpringApplication</code> concern; runners need their own API.</p>
<h2>How to run Phase 1 lifecycle proofs</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*PostConstructBeforeProxyTest'</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*ConstructorCircularDependencyTest'</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*RepositoryMiniUmbrellaTest'</span></span></code></pre></div>

<p>Docs: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/03-bean-lifecycle.md"><code>docs/03-bean-lifecycle.md</code></a></p>
<h2>One-liner</h2>
<blockquote>
<p>Know the lifecycle order. Startup transactional work needs a fully built context. And “no outer transaction” does not mean “no transaction” — repositories will invent mini ones.</p>
</blockquote>
<p>Next (Phase 2): <a href="https://jeffreyjose07.is-a.dev/blog/spring-hikari-hogging-and-optimistic-locking">HikariCP exhaustion, connection hogging, and optimistic locking</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[HikariCP Exhaustion, Connection Hogging, and Optimistic Locking]]></title>
      <description><![CDATA[Phase 2 of the spring-validation-lab: REQUIRES_NEW against a saturated pool, HTTP inside @Transactional after first SQL, and @Version under flash contention — all proven with H2 tests.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/spring-hikari-hogging-and-optimistic-locking</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/spring-hikari-hogging-and-optimistic-locking</guid>
      <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[java]]></category>
      <category><![CDATA[hikaricp]]></category>
      <category><![CDATA[transactions]]></category>
      <category><![CDATA[concurrency]]></category>
      <category><![CDATA[interview-prep]]></category>
      <category><![CDATA[testing]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/spring-hikari-hogging-and-optimistic-locking.png" />
      <content:encoded><![CDATA[<p>Series from <a href="https://github.com/jeffreyjose07/spring-validation-lab">spring-validation-lab</a>. Index: <a href="https://jeffreyjose07.is-a.dev/blog/proving-spring-internals-with-tests">Proving Spring Internals With Tests</a>.</p>
<p><strong>Previous:</strong> <a href="https://jeffreyjose07.is-a.dev/blog/spring-bean-lifecycle-and-mini-umbrellas">Bean lifecycle and mini-umbrellas</a><br><strong>This post:</strong> Phase 2 — pools, hogging, optimistic locking</p>
<p>Phase 1 was about whether Spring’s sticky notes fire. Phase 2 is about what happens when they fire <strong>too well</strong> under load: every transaction wants a scarce Hikari connection, nested <code>REQUIRES_NEW</code> wants a second one, slow HTTP keeps the first one checked out, and twenty buyers fight over one unit of stock.</p>
<p>We proved three failure modes on Java 21 / Spring Boot 4.1 with a deliberately tiny pool (max 10) and H2.</p>
<h2>Shared ground truth: connections are often lazy</h2>
<p><strong>Folklore:</strong> entering <code>@Transactional</code> always checks out a Hikari connection immediately.</p>
<p><strong>Lab:</strong> with JPA, the connection is often acquired on <strong>first SQL</strong>. Pure CPU / HTTP at the start of a transactional method may hold <strong>0</strong> connections until the first query/flush. Hogging and pool math should be reasoned from “first SQL,” not from method entry alone.</p>
<p>Say that in interviews. It changes how you narrate connection-hog timelines.</p>
<hr>
<h2>Trap 1 — <code>REQUIRES_NEW</code> vs pool size</h2>
<h3>Theory</h3>
<p>Normal <code>@Transactional</code> (REQUIRED): one connection for the TX duration (once acquired).</p>
<p><code>REQUIRES_NEW</code> on a nested bean: suspend outer TX, open a <strong>new</strong> TX → typically needs a <strong>second</strong> connection while the parent still holds the first.</p>
<p>Bad schedule with <code>maximumPoolSize = 10</code>:</p>
<ol>
<li>Ten threads enter parent TX</li>
<li>Each runs first SQL → each holds 1 connection → pool empty</li>
<li>Each calls nested <code>REQUIRES_NEW</code> → needs connection #11…#20</li>
<li>Hikari checkout timeouts / cascade failures</li>
</ol>
<p>This is not “Hikari is broken.” It is synchronous nested transactions under a saturated pool.</p>
<h3>Lab design</h3>
<ul>
<li><code>PaymentWithRequiresNewAuditService</code> — parent <code>@Transactional</code>, <code>saveAndFlush</code> (acquire conn), optional barrier, then nested call</li>
<li><code>NestedRequiresNewAuditService</code> — <code>@Transactional(REQUIRES_NEW)</code> audit write</li>
</ul>
<h3>Ground truth: H2 is too fast without a barrier</h3>
<p><strong>Folklore:</strong> 10 threads × <code>REQUIRES_NEW</code> always deadlocks a pool of 10.</p>
<p><strong>Lab:</strong> on H2, SQL is so fast that some parents finish before all ten hold a connection. The “deadlock window” never opens. We force the bad schedule with a <code>CountDownLatch</code>:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#E5C07B">auditLogRepository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">saveAndFlush</span><span style="color:#ABB2BF">(...);</span><span style="color:#7F848E;font-style:italic"> // hold parent connection</span></span>
<span class="line"><span style="color:#E5C07B">allParentsHolding</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">countDown</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#E5C07B">allParentsHolding</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">await</span><span style="color:#ABB2BF">(...);</span><span style="color:#7F848E;font-style:italic">         // wait until pool is full</span></span>
<span class="line"><span style="color:#E5C07B">nestedRequiresNewAuditService</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">writeIndependentAudit</span><span style="color:#ABB2BF">(paymentId);</span></span></code></pre></div>

<p>Production latency (real DB, GC, network) makes the bad schedule easier to hit “naturally.” Tests must manufacture it.</p>
<h3>How we proved it</h3>
<p>Test: <code>RequiresNewPoolExhaustionTest</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*RequiresNewPoolExhaustionTest'</span></span></code></pre></div>

<p>Assert: with 10 concurrent callers and the barrier, <strong>≥ 5</strong> fail getting the second connection (we allow races but demand real pain; a perfect 10/10 deadlock is ideal but not required for the lesson).</p>
<h3>What to do instead</h3>
<ul>
<li>Prefer async audit / outbox over synchronous <code>REQUIRES_NEW</code> under load</li>
<li>If you must nest, size the pool for <code>parents + nested</code> worst case — and admit that cost</li>
<li>Do not stack <code>REQUIRES_NEW</code> in hot request paths as a default “make it durable” hammer</li>
</ul>
<hr>
<h2>Trap 2 — connection hogging (HTTP inside the TX)</h2>
<h3>Theory</h3>
<p>Admin bulk price update:</p>
<ol>
<li>For each SKU, call manufacturer HTTP (slow)</li>
<li>Read/update product row</li>
<li>All inside one <code>@Transactional</code></li>
</ol>
<p>After the <strong>first</strong> SQL, Hikari connection stays checked out for the rest of the method — including every subsequent vendor round-trip. Ten parallel admins → pool empty → login / checkout die even though the DB is idle.</p>
<h3>Broken design (lab)</h3>
<p><code>BrokenBulkPriceUpdateService</code>:</p>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Transactional</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> void</span><span style="color:#61AFEF"> processBulkUpload</span><span style="color:#E06C75">(</span><span style="color:#E5C07B">List</span><span style="color:#56B6C2">&#x3C;</span><span style="color:#E06C75">PriceChange</span><span style="color:#56B6C2">></span><span style="color:#E06C75"> changes) {</span></span>
<span class="line"><span style="color:#C678DD">    for</span><span style="color:#E06C75"> (</span><span style="color:#E5C07B">PriceChange</span><span style="color:#E06C75"> change </span><span style="color:#C678DD">:</span><span style="color:#E06C75"> changes) {</span></span>
<span class="line"><span style="color:#C678DD">        if</span><span style="color:#E06C75"> (</span><span style="color:#E5C07B">vendorClient</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">verifyWithManufacturer</span><span style="color:#ABB2BF">(change)</span><span style="color:#E06C75">) { </span><span style="color:#7F848E;font-style:italic">// slow, inside TX</span></span>
<span class="line"><span style="color:#E5C07B">            Product</span><span style="color:#E06C75"> product </span><span style="color:#56B6C2">=</span><span style="color:#E5C07B"> productRepository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">findBySku</span><span style="color:#ABB2BF">(...);</span><span style="color:#7F848E;font-style:italic"> // acquires/holds conn</span></span>
<span class="line"><span style="color:#E5C07B">            product</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">setPrice</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">change</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">newPrice</span><span style="color:#ABB2BF">());</span></span>
<span class="line"><span style="color:#E5C07B">            toUpdate</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">add</span><span style="color:#ABB2BF">(product);</span></span>
<span class="line"><span style="color:#E06C75">        }</span></span>
<span class="line"><span style="color:#E06C75">    }</span></span>
<span class="line"><span style="color:#E5C07B">    productRepository</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">saveAll</span><span style="color:#ABB2BF">(toUpdate);</span></span>
<span class="line"><span style="color:#E06C75">}</span></span></code></pre></div>

<h3>Fixed design (lab)</h3>
<p><code>FixedBulkPriceUpdateService</code> — <strong>no</strong> <code>@Transactional</code> on the orchestrator:</p>
<ol>
<li>Do all vendor HTTP first (0 DB connections)</li>
<li>Call <code>DbBatchPriceService.batchUpdatePrices(valid)</code> — short <code>@Transactional</code> batch</li>
</ol>
<p>Network work outside the umbrella. DB work in a tight proxy call.</p>
<h3>How we proved it</h3>
<p>Test: <code>ConnectionHoggingTest</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*ConnectionHoggingTest'</span></span></code></pre></div>

<p>Assert roughly:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Mid-flight Hikari active connections</th>
</tr>
</thead>
<tbody><tr>
<td>Broken (HTTP inside TX after SQL)</td>
<td>pool fully checked out (<strong>10</strong>)</td>
</tr>
<tr>
<td>Fixed (HTTP outside TX)</td>
<td>during vendor phase <strong>≤ 1</strong> (ideally 0)</td>
</tr>
</tbody></table>
<p>Watch <code>LabLog</code> banners: <code>PHASE2-HOG</code> broken vs fixed.</p>
<h3>Interview nuance</h3>
<p>If someone says “never do HTTP in a transaction,” refine it: never do HTTP <strong>while holding a DB connection</strong>. With lazy acquisition, HTTP <em>before</em> first SQL inside <code>@Transactional</code> is still a bad habit (long TX, lock duration later) but it is not the same as pool starvation. After first SQL, it is pool starvation.</p>
<hr>
<h2>Trap 3 — optimistic locking under flash traffic</h2>
<h3>Theory</h3>
<p>One VIP SKU, stock = 1, twenty concurrent buyers. Pessimistic <code>SELECT … FOR UPDATE</code> serializes and holds row locks. Optimistic <code>@Version</code>:</p>
<ol>
<li>Each TX reads <code>version = N</code></li>
<li>Winner flushes <code>UPDATE … SET stock=0, version=N+1 WHERE id=? AND version=N</code> → 1 row updated</li>
<li>Losers’ UPDATE matches 0 rows → Hibernate <code>StaleStateException</code> → Spring often wraps as <code>ObjectOptimisticLockingFailureException</code></li>
</ol>
<p>Fast fail. No long lock waits. Callers retry or show “sold out.”</p>
<h3>Lab design</h3>
<div class="code-block-wrap" data-lang="java"><span class="code-lang" aria-hidden="true">java</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">@</span><span style="color:#E5C07B">Entity</span></span>
<span class="line"><span style="color:#C678DD">public</span><span style="color:#C678DD"> class</span><span style="color:#E5C07B"> InventoryItem</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">    // ...</span></span>
<span class="line"><span style="color:#ABB2BF">    @</span><span style="color:#E5C07B">Version</span></span>
<span class="line"><span style="color:#C678DD">    private</span><span style="color:#E5C07B"> Integer</span><span style="color:#E06C75"> version</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p><code>InventoryPurchaseService.purchaseOne</code>:</p>
<ul>
<li>read by SKU</li>
<li>if stock &lt; 1 → <code>IllegalStateException(&quot;Out of stock&quot;)</code></li>
<li>decrement, <code>saveAndFlush</code></li>
<li>rethrow optimistic failures after logging</li>
</ul>
<h3>Ground truth: exception names</h3>
<p>Logs may show Hibernate <code>StaleStateException</code> / batch failures <strong>before</strong> Spring’s <code>ObjectOptimisticLockingFailureException</code>. Same concept. Name both if probed.</p>
<h3>How we proved it</h3>
<p>Test: <code>OptimisticLockingTest</code></p>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*OptimisticLockingTest'</span></span></code></pre></div>

<p>Seed: one <code>IPHONE-VIP</code> with stock 1. Fire 20 threads.</p>
<p>Assert:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Expected</th>
</tr>
</thead>
<tbody><tr>
<td>successes</td>
<td><strong>exactly 1</strong></td>
</tr>
<tr>
<td>final stock</td>
<td><strong>0</strong></td>
</tr>
<tr>
<td>optimistic + out-of-stock failures</td>
<td><strong>19</strong></td>
</tr>
<tr>
<td>optimistic failures</td>
<td><strong>&gt; 0</strong> (not only out-of-stock)</td>
</tr>
</tbody></table>
<p>That last assert matters: we proved the version collision path, not only “later threads saw stock 0.”</p>
<hr>
<h2>How Phase 2 fits Principal-level answers</h2>
<p>Senior answers list tools. Principal answers connect <strong>resource budgets</strong>:</p>
<ul>
<li>Connections are a hard cap (pool size)</li>
<li>Every open TX after first SQL is a checked-out connection</li>
<li>Nested TX multiplies demand</li>
<li>Remote I/O multiplies hold time</li>
<li>Contention strategy (<code>@Version</code> vs locks) is a product decision under flash sales</li>
</ul>
<p>The lab makes those sentences falsifiable.</p>
<h2>How to run Phase 2</h2>
<div class="code-block-wrap" data-lang="bash"><span class="code-lang" aria-hidden="true">bash</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*RequiresNewPoolExhaustionTest'</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*ConnectionHoggingTest'</span></span>
<span class="line"><span style="color:#61AFEF">./gradlew</span><span style="color:#98C379"> test</span><span style="color:#D19A66"> --tests</span><span style="color:#98C379"> '*OptimisticLockingTest'</span></span></code></pre></div>

<p>Docs: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/04-connection-pools-and-locking.md"><code>docs/04-connection-pools-and-locking.md</code></a><br>Ground truth: <a href="https://github.com/jeffreyjose07/spring-validation-lab/blob/main/docs/ground-truth.md"><code>docs/ground-truth.md</code></a></p>
<h2>One-liner</h2>
<blockquote>
<p>Never hold a DB connection across remote I/O. Never stack synchronous <code>REQUIRES_NEW</code> under a saturated pool. Prefer optimistic locking when collisions are rare or you need fail-fast under flash traffic.</p>
</blockquote>
<hr>
<p>That closes Phase 1–2 of the lab. I am deliberately not racing into Kafka outbox / saga write-ups until these proofs feel boring. If you interview Spring-heavy backend roles: clone the repo, run one red test and one green test, and keep your own ground-truth file for every place Boot 4 disagreed with the slide deck.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Hardening the Chat Platform: Deps, Deep Health, and CI Pings]]></title>
      <description><![CDATA[Merged five Dependabot frontend PRs, fixed React 19 + Docker builds, opened actuator health for probes, and added caching and rate limits—then moved scheduled pings into the app repo so nothing doubles up.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/hardening-the-chat-platform-deps-deep-health-and-ci-pings</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/hardening-the-chat-platform-deps-deep-health-and-ci-pings</guid>
      <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[scalable-chat-platform]]></category>
      <category><![CDATA[spring-boot]]></category>
      <category><![CDATA[react]]></category>
      <category><![CDATA[github-actions]]></category>
      <category><![CDATA[health]]></category>
      <category><![CDATA[deployment]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/hardening-the-chat-platform-deps-deep-health-and-ci-pings.png" />
      <content:encoded><![CDATA[<p>This is a working log for <a href="https://github.com/jeffreyjose07/scalable-chat-platform">scalable-chat-platform</a>: what got merged, why the order mattered, and where health checks and GitHub Actions ended up. The app already exposes a <strong>deep</strong> checklist at <code>GET /api/health/status</code> (PostgreSQL, MongoDB, Redis); Actuator health is in the mix where Spring is configured for it. This session was about <strong>not</strong> treating that endpoint as free work for the world, and about <strong>not</strong> running the same cron in two repositories.</p>
<h2>Dependabot and merge order</h2>
<p>Five frontend-related bumps landed after resolving conflicts:</p>
<ul>
<li><strong>Headless UI</strong> → <code>2.2.9</code></li>
<li><strong>axios</strong> → <code>1.12.2</code></li>
<li><strong>@hookform/resolvers</strong> → <code>5.2.2</code></li>
<li><strong>react-hook-form</strong> → <code>7.65.0</code></li>
<li><strong>React 19</strong> with aligned types</li>
</ul>
<p><strong>Headless UI went in before React 19</strong> on purpose. Older Headless UI majors and React 19 do not always play nice together; ordering the merge avoids a broken peer dependency surface while the lockfile settles.</p>
<p>After React 19 landed, <strong><code>useRef</code> typing in search-related components</strong> needed tightening—straightforward breakage from stricter generics, fixed in place rather than suppressed with <code>any</code>.</p>
<h2>Health checks and abuse resistance</h2>
<p>Nothing changed about what “healthy” means: the deep route still aggregates real dependencies. The hardening story is <strong>who can hammer it</strong> and <strong>how often the expensive path runs</strong>.</p>
<p><strong>Security configuration</strong> allows anonymous access to actuator-style health probes where uptime tools need them: <code>/api/actuator/health/**</code> and <code>/actuator/health/**</code> (see commit <code>13580ca</code> on the app repo). That keeps external monitors and container health checks honest without opening the rest of the admin surface.</p>
<p>To avoid turning <code>/api/health/status</code> into an accidental DDoS against your own databases:</p>
<ul>
<li><strong>TTL caching</strong> on the deep health response so repeat hits within a window reuse a stored result.</li>
<li>A <strong>separate rate-limit bucket</strong> for health traffic so normal API throttles stay sensible.</li>
<li><strong>Broader URL patterns</strong> on the limiting filter (<code>/api/*</code>, <code>/actuator/*</code>, <code>/health</code>) so probes and API traffic are categorized consistently.</li>
</ul>
<p>Configuration lives under <code>app.health.*</code> (cache TTL and related knobs in <code>application.yml</code>). <strong>Tests</strong> cover the health controller—including cache behavior—so regressions fail in CI instead of production.</p>
<h2>Render, Docker, and the frontend install step</h2>
<p>Render’s Docker build failed during <code>frontendInstall</code>: <strong>Testing Library React v13</strong> and <strong>React 19</strong> do not belong in the same install graph. The fix was to move to <strong>@testing-library/react v16</strong>, add <strong>@testing-library/dom</strong>, bump <strong>@testing-library/user-event</strong>, and <strong>regenerate <code>package-lock.json</code></strong> so CI and Docker see one consistent tree.</p>
<h2>GitHub Actions: one place for scheduled pings</h2>
<p>The app repository now owns <strong><code>.github/workflows/ping-app-health.yml</code></strong>: cron roughly every five minutes plus <code>workflow_dispatch</code>, <code>APP_BASE_URL</code> secret, <code>curl</code>, and <strong><code>jq</code></strong> for a readable summary. That is where scheduled wake-ups belong—same repo as the service, secret colocated with deployment docs.</p>
<p>Earlier I had experimented with a <strong>portfolio</strong> workflow (<code>keep-alive.yml</code>) hitting the Render URL from <a href="https://github.com/jeffreyjose07/jeffreyjose07.github.io">jeffreyjose07.github.io</a>. That duplicated the cron and split configuration across projects. <strong>I removed it from the Pages repo</strong> so there is exactly one scheduler: the chat platform workflow. No duplicate pings, no need for <code>APP_BASE_URL</code> on the blog side.</p>
<h2>What I deliberately did not commit</h2>
<p>Local <code>backend/src/main/resources/static/static/js/main.*</code> bundles from a dev build stayed out of git—noise, not source of truth.</p>
<h2>Links and commits (app repo)</h2>
<ul>
<li><a href="https://github.com/jeffreyjose07/scalable-chat-platform/commit/13580ca"><code>13580ca</code></a> — actuator health paths reachable for anonymous probes.</li>
<li><a href="https://github.com/jeffreyjose07/scalable-chat-platform/commit/f04579c"><code>f04579c</code></a> — ping workflow, Testing Library / lockfile fix, health cache, rate limits, tests, and config.</li>
</ul>
<p>If you fork the platform, copy the <strong>ping workflow</strong> and set <strong><code>APP_BASE_URL</code></strong> in that repository’s secrets; keep the <strong>portfolio</strong> repository focused on static site and blog builds only.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Redesigning the Portfolio: Teal, Fonts, and Timelines]]></title>
      <description><![CDATA[A deep audit of the portfolio revealed broken animations, a split design system, and the violet monoculture. Here's how I fixed it.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/redesigning-the-portfolio-teal-fonts-and-timelines</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/redesigning-the-portfolio-teal-fonts-and-timelines</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[design]]></category>
      <category><![CDATA[frontend]]></category>
      <category><![CDATA[portfolio]]></category>
      <category><![CDATA[ui-design]]></category>
      <category><![CDATA[animation]]></category>
      <category><![CDATA[css]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/redesigning-the-portfolio-teal-fonts-and-timelines.png" />
      <content:encoded><![CDATA[<p>I&#39;ve been staring at this portfolio for months. It looked fine. Sections were polished, animations ran smoothly, the glassmorphism felt premium. Or so I thought.</p>
<p>Running a proper design audit against current frontend trends changed that. Quickly.</p>
<h2>What the Audit Found</h2>
<p>The first thing that jumped out was something I&#39;d describe as a <strong>split personality problem</strong>. The Hero, Projects, and Contact sections used my Tailwind CSS custom property system — <code>bg-background</code>, <code>text-primary</code>, <code>glass-card</code> — everything properly tokenized. But Skills and Experience were hardcoding raw utility classes: <code>bg-gray-800</code>, <code>text-gray-600</code>, <code>bg-blue-600</code>. They came from a different project, and it showed. Scrolling through the portfolio felt like visiting two different websites stapled together.</p>
<p>The second issue was more embarrassing: <strong>two animations were completely broken</strong>. The floating blobs in the Hero used <code>animate-float</code>, and Skills/Experience used <code>animate-slide-up</code>. Neither keyframe was defined in <code>tailwind.config.ts</code>. They just silently failed — the elements appeared without animation, and I&#39;d never noticed because the degradation was invisible.</p>
<p>Third: <strong>all animations fired on page load</strong>, not when sections entered the viewport. Elements deep in the page had already &quot;animated&quot; before a user could possibly see them. The effect was wasted.</p>
<p>And then there was the elephant in the room: <strong>electric violet everywhere</strong>, paired with Inter and Outfit. The same purple-gradient-on-dark combo that&#39;s on roughly every developer portfolio generated in the last two years. Not a good look when you&#39;re trying to stand out.</p>
<h2>Fixing the Foundation</h2>
<p>I started with the quick wins. Added the missing <code>float</code> and <code>slide-up</code> keyframes to <code>tailwind.config.ts</code>. Also slowed the hero&#39;s pulsing mesh animation from 4 seconds to 8 — at 4s it was subtly nauseating on long page dwell.</p>
<p>For scroll-triggered animations, I wrote a small <code>useInView</code> hook using the browser&#39;s native <code>IntersectionObserver</code> API. Each section now carries a ref, and animations only trigger when the element crosses the viewport threshold. The hook disconnects the observer after the first trigger — no repeated firing, no React render loops.</p>
<div class="code-block-wrap" data-lang="typescript"><span class="code-lang" aria-hidden="true">typescript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">export</span><span style="color:#C678DD"> function</span><span style="color:#61AFEF"> useInView</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75;font-style:italic">threshold</span><span style="color:#56B6C2"> =</span><span style="color:#D19A66"> 0.15</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">  const</span><span style="color:#E5C07B"> ref</span><span style="color:#56B6C2"> =</span><span style="color:#61AFEF"> useRef</span><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E5C07B">HTMLElement</span><span style="color:#ABB2BF">>(</span><span style="color:#D19A66">null</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#C678DD">  const</span><span style="color:#ABB2BF"> [</span><span style="color:#E5C07B">isInView</span><span style="color:#ABB2BF">, </span><span style="color:#E5C07B">setIsInView</span><span style="color:#ABB2BF">] </span><span style="color:#56B6C2">=</span><span style="color:#61AFEF"> useState</span><span style="color:#ABB2BF">(</span><span style="color:#D19A66">false</span><span style="color:#ABB2BF">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#61AFEF">  useEffect</span><span style="color:#ABB2BF">(() </span><span style="color:#C678DD">=></span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C678DD">    const</span><span style="color:#E5C07B"> el</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> ref</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">current</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">    if</span><span style="color:#ABB2BF"> (</span><span style="color:#56B6C2">!</span><span style="color:#E06C75">el</span><span style="color:#ABB2BF">) </span><span style="color:#C678DD">return</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">    const</span><span style="color:#E5C07B"> observer</span><span style="color:#56B6C2"> =</span><span style="color:#C678DD"> new</span><span style="color:#61AFEF"> IntersectionObserver</span><span style="color:#ABB2BF">(</span></span>
<span class="line"><span style="color:#ABB2BF">      ([</span><span style="color:#E06C75;font-style:italic">entry</span><span style="color:#ABB2BF">]) </span><span style="color:#C678DD">=></span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#C678DD">        if</span><span style="color:#ABB2BF"> (</span><span style="color:#E5C07B">entry</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">isIntersecting</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#61AFEF">          setIsInView</span><span style="color:#ABB2BF">(</span><span style="color:#D19A66">true</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#E5C07B">          observer</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">unobserve</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">el</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#ABB2BF">        }</span></span>
<span class="line"><span style="color:#ABB2BF">      },</span></span>
<span class="line"><span style="color:#ABB2BF">      { </span><span style="color:#E06C75">threshold</span><span style="color:#ABB2BF"> }</span></span>
<span class="line"><span style="color:#ABB2BF">    );</span></span>
<span class="line"><span style="color:#E5C07B">    observer</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">observe</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">el</span><span style="color:#ABB2BF">);</span></span>
<span class="line"><span style="color:#C678DD">    return</span><span style="color:#ABB2BF"> () </span><span style="color:#C678DD">=></span><span style="color:#E5C07B"> observer</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">disconnect</span><span style="color:#ABB2BF">();</span></span>
<span class="line"><span style="color:#ABB2BF">  }, [</span><span style="color:#E06C75">threshold</span><span style="color:#ABB2BF">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C678DD">  return</span><span style="color:#ABB2BF"> { </span><span style="color:#E06C75">ref</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">isInView</span><span style="color:#ABB2BF"> };</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<p>Twenty-five lines. No library needed.</p>
<h2>The Color System</h2>
<p>I replaced the violet primary — <code>hsl(263 70% 50%)</code> — with <strong>emerald teal</strong> at <code>hsl(162 75% 38%)</code>. The choice wasn&#39;t arbitrary. Teal at that lightness sits in a useful spot: dark enough to pass WCAG AA contrast on white backgrounds, vivid enough to glow against dark ones. The companion gradient goes teal-to-cyan, which has a directional warmth that violet-to-purple lacks.</p>
<p>The gradient text on the name now reads white-to-teal in dark mode, and teal-to-cyan in light mode. The mesh background radials, the glow border effect, the section dividers — all updated to match. It&#39;s a surprisingly large surface area when you start counting.</p>
<h2>Typography</h2>
<p>Swapped <strong>Inter</strong> for <strong>Plus Jakarta Sans</strong> as the body font, and <strong>Outfit</strong> for <strong>Syne</strong> as the heading font. Syne is wide and geometric at heavy weights — it brings a kind of confident authority that Outfit&#39;s roundness doesn&#39;t. Plus Jakarta Sans has more character than Inter while staying clean enough for long-form reading. Both are available on Google Fonts and load with the same preconnect strategy.</p>
<p>The pairing feels more deliberate. Less off-the-shelf.</p>
<h2>Skills and Experience: Proper Redesigns</h2>
<p>The Skills section got torn down and rebuilt. The old version was a flat badge cloud inside gray cards — every skill had identical visual weight, so <code>Kubernetes</code> sat next to <code>Unit Testing</code> as equals. That&#39;s not how expertise actually works.</p>
<p>The new layout has two tiers. <strong>Signature expertise</strong> — Spring Boot &amp; WebFlux, Apache Kafka, Microservices Architecture, Kubernetes &amp; GCP, PostgreSQL &amp; MongoDB, Reactive Programming — displayed as larger bordered cards with category tags. Below that, a <strong>full toolkit</strong> organized by domain (Languages, Data, DevOps, Architecture, Quality). Recruiters scanning in six seconds see the differentiators first.</p>
<p>Experience got a more significant transformation. The old stacked card layout was a direct CV-to-web translation — readable, but forgettable. I replaced it with a <strong>vertical timeline</strong>: a connector line runs down the left side on desktop, with timeline dots marking each role, dates on a perpendicular axis, and entries that stagger-animate in on scroll. The career arc becomes visually legible at a glance.</p>
<h2>The Smaller Fixes</h2>
<p>A few things that don&#39;t make headlines but matter:</p>
<p>The Contact section had three hardcoded <code>text-white</code> labels for Email, Phone, and Location. They worked in dark mode. In light mode, white text on a near-white background — invisible. Fixed to <code>text-muted-foreground</code>.</p>
<p>The Hero subtitle was vague: <em>&quot;Code craftsman • Tech explorer • Minimalist&quot;</em>. Pretty, but says nothing about what I actually do. Changed to <em>&quot;Senior Backend Engineer • Distributed Systems • 6 yrs @ Jio&quot;</em>. Concrete. Scannable.</p>
<p>The academic projects section had its heading in <code>text-muted-foreground</code> — visually de-emphasized to the point of looking apologetic. Given these are worth showing, gave them a proper gradient heading treatment.</p>
<p>And the footer now closes with a small monospace line: <code>&gt; crafted with care, deployed with confidence</code>. A nod to the terminal aesthetic of the blog.</p>
<h2>Reflecting on Design Complacency</h2>
<p>The honest thing to admit is that most of these issues weren&#39;t introduced all at once. They accumulated — a section copied from somewhere, a color variable updated in one place but not another, an animation added without checking the keyframe existed. Design debt works exactly like technical debt: it&#39;s quiet until you look directly at it.</p>
<p>Running an explicit audit forced the looking. The portfolio is sharper for it.</p>
<hr>
<p><em>All changes are live. The build is clean, the animations fire when they should, and the violet is gone.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Optimizing Performance and Pagination: A Deep Dive into Web Vitals]]></title>
      <description><![CDATA[How I refactored the blog's pagination system for better SEO, optimized image loading strategies to boost Core Web Vitals, and fixed critical accessibility issues.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/optimizing-performance-and-pagination-a-deep-dive-into-web</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/optimizing-performance-and-pagination-a-deep-dive-into-web</guid>
      <pubDate>Wed, 17 Dec 2025 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[optimization]]></category>
      <category><![CDATA[web-development]]></category>
      <category><![CDATA[performance]]></category>
      <category><![CDATA[accessibility]]></category>
      <category><![CDATA[seo]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/optimizing-performance-and-pagination-a-deep-dive-into-web.png" />
      <content:encoded><![CDATA[<p>As this portfolio and blog continue to grow, maintaining peak performance and a seamless user experience becomes increasingly challenging. Today, I tackled several technical debt items that were affecting both <strong>SEO</strong> and <strong>Core Web Vitals</strong>. This post details the journey of refactoring the pagination system, implementing advanced image loading strategies, and resolving accessibility bottlenecks identified by Google PageSpeed Insights.</p>
<h2>the pagination problem</h2>
<p>Initially, the blog&#39;s pagination was simple but brittle. The build script generated flat HTML files like <code>page2.html</code> and <code>page3.html</code>. While functional, this approach had significant downsides:</p>
<ol>
<li><strong>URL Structure</strong>: <code>jeffreyjose07.is-a.dev/blog/page2.html</code> is not clean or idiomatic. The standard convention is a directory-based structure like <code>/blog/page/2/</code>.</li>
<li><strong>Scalability</strong>: Flat files clutter the root directory and make URL management cumbersome.</li>
<li><strong>SEO</strong>: Search engines prefer structured, predictable URL hierarchies.</li>
</ol>
<h3>the solution: directory-based routing</h3>
<p>I refactored the Node.js build script (<code>blog/scripts/build.js</code>) to generate a nested directory structure. Instead of writing <code>page${i}.html</code>, the script now creates a directory for each page:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">// Old approach</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">// fs.writeFileSync(path.join(OUTPUT_DIR, `page${i + 1}.html`), html);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#7F848E;font-style:italic">// New approach</span></span>
<span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> pageDir</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> path</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">join</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">OUTPUT_DIR</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'page'</span><span style="color:#ABB2BF">, (</span><span style="color:#E06C75">i</span><span style="color:#56B6C2"> +</span><span style="color:#D19A66"> 1</span><span style="color:#ABB2BF">).</span><span style="color:#61AFEF">toString</span><span style="color:#ABB2BF">());</span></span>
<span class="line"><span style="color:#C678DD">if</span><span style="color:#ABB2BF"> (</span><span style="color:#56B6C2">!</span><span style="color:#E5C07B">fs</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">existsSync</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">pageDir</span><span style="color:#ABB2BF">)) {</span></span>
<span class="line"><span style="color:#E5C07B">    fs</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">mkdirSync</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">pageDir</span><span style="color:#ABB2BF">, { </span><span style="color:#E06C75">recursive</span><span style="color:#ABB2BF">: </span><span style="color:#D19A66">true</span><span style="color:#ABB2BF"> });</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span>
<span class="line"><span style="color:#E5C07B">fs</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">writeFileSync</span><span style="color:#ABB2BF">(</span><span style="color:#E5C07B">path</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">join</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">pageDir</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'index.html'</span><span style="color:#ABB2BF">), </span><span style="color:#E06C75">html</span><span style="color:#ABB2BF">);</span></span></code></pre></div>

<p>This simple change transforms the URL from <code>/blog/page2.html</code> to <code>/blog/page/2/</code>, which is cleaner, professional, and more SEO-friendly. I also updated the pagination links in the template to respect this new structure:</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">&#x3C;!-- Before --></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">a</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"/blog/page2.html"</span><span style="color:#ABB2BF">>2&#x3C;/</span><span style="color:#E06C75">a</span><span style="color:#ABB2BF">></span></span>
<span class="line"></span>
<span class="line"><span style="color:#7F848E;font-style:italic">&#x3C;!-- After --></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">a</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"/blog/page/2/"</span><span style="color:#ABB2BF">>2&#x3C;/</span><span style="color:#E06C75">a</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<h2>optimizing core web vitals</h2>
<p>A recent PageSpeed Insights analysis revealed a mobile performance score of <strong>91</strong>, which is good, but I aimed for perfection. The primary bottlenecks were <strong>Largest Contentful Paint (LCP)</strong> and <strong>Cumulative Layout Shift (CLS)</strong> caused by unoptimized images.</p>
<h3>from background-image to img tags</h3>
<p>The blog index originally displayed post thumbnails using <code>div</code> elements with <code>background-image</code>. While this makes it easy to handle different aspect ratios using <code>background-size: cover</code>, it has a major performance drawback: browser preloaders cannot discover background images as easily as <code>&lt;img&gt;</code> tags, delaying the LCP.</p>
<p>I refactored the template to use semantic <code>&lt;img&gt;</code> tags with modern CSS:</p>
<div class="code-block-wrap" data-lang="css"><span class="code-lang" aria-hidden="true">css</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#D19A66">.post-thumbnail</span><span style="color:#E06C75"> img</span><span style="color:#ABB2BF"> {</span></span>
<span class="line"><span style="color:#ABB2BF">    width: </span><span style="color:#D19A66">100</span><span style="color:#E06C75">%</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    height: </span><span style="color:#D19A66">100</span><span style="color:#E06C75">%</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">    object-fit: </span><span style="color:#D19A66">cover</span><span style="color:#ABB2BF">; </span><span style="color:#7F848E;font-style:italic">/* Replicates background-size: cover */</span></span>
<span class="line"><span style="color:#ABB2BF">    transition: transform </span><span style="color:#D19A66">0.5</span><span style="color:#E06C75">s</span><span style="color:#D19A66"> ease</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<h3>intelligent loading strategy</h3>
<p>To further improve LCP, I implemented a conditional loading strategy. The browser shouldn&#39;t lazy-load images that are in the initial viewport, nor should it eagerly load images that are off-screen.</p>
<p>I updated the build script to apply <code>loading=&quot;eager&quot;</code> to the first 3 posts on the first page, and <code>loading=&quot;lazy&quot;</code> to everything else. Additionally, for the main profile image on the homepage, I added <code>fetchPriority=&quot;high&quot;</code> to explicitly signal its importance to the browser&#39;s resource scheduler.</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#7F848E;font-style:italic">// Eager load images for the first 3 posts on the first page, lazy load others</span></span>
<span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> loadingAttr</span><span style="color:#56B6C2"> =</span><span style="color:#ABB2BF"> (</span><span style="color:#E06C75">i</span><span style="color:#56B6C2"> ===</span><span style="color:#D19A66"> 0</span><span style="color:#56B6C2"> &#x26;&#x26;</span><span style="color:#E06C75"> index</span><span style="color:#56B6C2"> &#x3C;</span><span style="color:#D19A66"> 3</span><span style="color:#ABB2BF">) </span><span style="color:#C678DD">?</span><span style="color:#98C379"> 'eager'</span><span style="color:#C678DD"> :</span><span style="color:#98C379"> 'lazy'</span><span style="color:#ABB2BF">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E06C75">thumbnailHtml</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `&#x3C;div class="post-thumbnail"></span></span>
<span class="line"><span style="color:#98C379">    &#x3C;img src="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">thumbSrc</span><span style="color:#C678DD">}</span><span style="color:#98C379">" alt="</span><span style="color:#C678DD">${</span><span style="color:#E5C07B">post</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">title</span><span style="color:#C678DD">}</span><span style="color:#98C379">" loading="</span><span style="color:#C678DD">${</span><span style="color:#E06C75">loadingAttr</span><span style="color:#C678DD">}</span><span style="color:#98C379">" width="400" height="225"></span></span>
<span class="line"><span style="color:#98C379">&#x3C;/div>`</span><span style="color:#ABB2BF">;</span></span></code></pre></div>

<p>This ensures the most critical content loads immediately, while conserving bandwidth for the rest.</p>
<h3>optimizing font delivery</h3>
<p>In a second round of optimization, PageSpeed Insights still flagged Google Fonts as a render-blocking resource. To address this, I switched to a non-blocking loading strategy using <code>rel=&quot;preload&quot;</code> combined with an <code>onload</code> handler.</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">link</span><span style="color:#D19A66"> rel</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"preload"</span><span style="color:#D19A66"> as</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"style"</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"..."</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">link</span><span style="color:#D19A66"> rel</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"stylesheet"</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"..."</span><span style="color:#D19A66"> media</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"print"</span><span style="color:#D19A66"> onload</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"</span><span style="color:#E5C07B">this</span><span style="color:#98C379">.</span><span style="color:#E06C75">media</span><span style="color:#56B6C2">=</span><span style="color:#98C379">'all'"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">noscript</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">link</span><span style="color:#D19A66"> rel</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"stylesheet"</span><span style="color:#D19A66"> href</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"..."</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;/</span><span style="color:#E06C75">noscript</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<p>This technique allows the browser to fetch the font stylesheet asynchronously without blocking the initial paint, significantly improving the <strong>First Contentful Paint (FCP)</strong> metric.</p>
<h2>accessibility wins</h2>
<p>Performance isn&#39;t just about speed; it&#39;s about usability. The audit flagged several <strong>accessibility</strong> issues that needed immediate attention:</p>
<ol>
<li><p><strong>Icon-Only Buttons</strong>: Social media links and the theme toggle button lacked text labels, making them invisible to screen readers. I added <code>aria-label</code> attributes to all icon-only interactions.</p>
<div class="code-block-wrap" data-lang="jsx"><span class="code-lang" aria-hidden="true">jsx</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">a</span><span style="color:#ABB2BF"> </span></span>
<span class="line"><span style="color:#D19A66;font-style:italic">  href</span><span style="color:#56B6C2">=</span><span style="color:#C678DD">{</span><span style="color:#E5C07B">social</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">href</span><span style="color:#C678DD">}</span><span style="color:#ABB2BF"> </span></span>
<span class="line"><span style="color:#D19A66;font-style:italic">  aria-label</span><span style="color:#56B6C2">=</span><span style="color:#C678DD">{</span><span style="color:#E5C07B">social</span><span style="color:#ABB2BF">.</span><span style="color:#E06C75">label</span><span style="color:#C678DD">}</span><span style="color:#7F848E;font-style:italic"> // Added for accessibility</span></span>
<span class="line"><span style="color:#D19A66;font-style:italic">  target</span><span style="color:#56B6C2">=</span><span style="color:#98C379">"_blank"</span></span>
<span class="line"><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E5C07B">social.icon</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;/</span><span style="color:#E06C75">a</span><span style="color:#ABB2BF">></span></span></code></pre></div>

</li>
<li><p><strong>Contrast Ratios</strong>: The &quot;Email&quot;, &quot;Phone&quot;, and &quot;Location&quot; labels in the contact section used a muted color that failed contrast guidelines on dark backgrounds. I updated the text color from <code>text-zinc-400</code> to <code>text-white</code> to ensure maximum legibility for all users.</p>
</li>
<li><p><strong>Heading Hierarchy</strong>: I restructured the heading levels in the Skills and Education sections (changing <code>CardTitle</code> to render as <code>h3</code> and adjusting nested headings) to ensure a strictly sequential descending order, which is crucial for screen reader navigation.</p>
</li>
<li><p><strong>Layout Stability</strong>: I added explicit <code>width</code> and <code>height</code> attributes to the main profile avatar. This allows the browser to reserve space for the image before it loads, preventing jarring layout shifts (CLS).</p>
</li>
</ol>
<h2>conclusion</h2>
<p>These changes might seem small individually, but collectively they represent a significant step forward in engineering quality. By refactoring the pagination, we now have a robust URL structure. By optimizing images, we&#39;ve reduced the LCP and improved the mobile experience. And by fixing accessibility issues, we&#39;ve ensured the site is inclusive for everyone.</p>
<p>The journey of optimization is never truly finished, but today&#39;s updates bring us much closer to that 100/100 score.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Automating Terminal-Style Thumbnails]]></title>
      <description><![CDATA[How I built a custom thumbnail generator using Puppeteer to create consistent, terminal-themed open graph images for every blog post.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/automating-terminal-style-thumbnails</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/automating-terminal-style-thumbnails</guid>
      <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[automation]]></category>
      <category><![CDATA[design]]></category>
      <category><![CDATA[javascript]]></category>
      <category><![CDATA[nodejs]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/automating-terminal-style-thumbnails.png" />
      <content:encoded><![CDATA[<p>I recently decided to give my blog a visual refresh. While I loved the content, the thumbnails were a mix of generic gradients and random screenshots. I wanted something that felt cohesive, technical, and aligned with the &quot;terminal/cyberpunk&quot; aesthetic of my portfolio.</p>
<p>Instead of manually designing a cover image for every new post in Figma or Canva, I did what any engineer would do: I wrote a script to do it for me.</p>
<h2>The Goal</h2>
<p>I wanted thumbnails that looked like a terminal window executing a command related to the post. For example:</p>
<ul>
<li>A backend post might show a <code>curl</code> request.</li>
<li>A personal post might show a <code>cat journal.md</code> command.</li>
<li>A database post might show a <code>psql</code> query.</li>
</ul>
<p>The result needed to be a high-quality PNG that could be used as an Open Graph image (for Twitter/LinkedIn cards) and as the header image on the blog itself.</p>
<h2>The Tech Stack</h2>
<ul>
<li><strong>Node.js</strong>: The runtime for the script.</li>
<li><strong>Puppeteer</strong>: A headless Chrome Node.js API. This is the secret sauce. It allows me to render HTML/CSS exactly as a browser would and take a screenshot.</li>
<li><strong>HTML/CSS</strong>: To design the &quot;terminal window&quot; layout.</li>
</ul>
<h2>The Implementation</h2>
<h3>1. The Template</h3>
<p>First, I created an HTML template (<code>template.html</code>) that defines the look of the terminal. It uses Flexbox to center a &quot;window&quot; div, and CSS to style the window controls (the red, yellow, and green dots) and the dark background.</p>
<p>I used Google Fonts to load <strong>JetBrains Mono</strong> for the terminal text and <strong>Outfit</strong> for the window title, ensuring typography matches the rest of my site.</p>
<div class="code-block-wrap" data-lang="html"><span class="code-lang" aria-hidden="true">html</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"terminal-window"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"window-header"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">        &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"window-controls"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">            &#x3C;!-- Mac-style dots --></span></span>
<span class="line"><span style="color:#ABB2BF">            &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"control-dot red"</span><span style="color:#ABB2BF">>&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">            &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"control-dot yellow"</span><span style="color:#ABB2BF">>&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">            &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"control-dot green"</span><span style="color:#ABB2BF">>&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">        &#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">        &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"window-title"</span><span style="color:#ABB2BF">>~/blog/posts/{{windowTitle}}&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;</span><span style="color:#E06C75">div</span><span style="color:#D19A66"> class</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"terminal-content"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">        {{terminalContent}}</span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;/</span><span style="color:#E06C75">div</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<h3>2. The Generator Script</h3>
<p>The script (<code>generate.js</code>) reads my blog&#39;s configuration and post metadata. It then launches a headless browser instance using Puppeteer.</p>
<p>For each post, it:</p>
<ol>
<li>Determines the &quot;primary tag&quot; (e.g., <code>backend</code>, <code>personal</code>, <code>frontend</code>).</li>
<li>Selects a corresponding &quot;command pattern&quot; based on that tag.</li>
<li>Injects the post title and the generated command into the HTML template.</li>
<li>Takes a screenshot of the rendered page.</li>
<li>Saves the image to <code>public/assets/thumbnails/</code>.</li>
</ol>
<p>Here&#39;s a snippet of the logic that maps tags to terminal commands:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#C678DD">switch</span><span style="color:#ABB2BF"> (</span><span style="color:#E06C75">primaryCategory</span><span style="color:#ABB2BF">) {</span></span>
<span class="line"><span style="color:#C678DD">    case</span><span style="color:#98C379"> 'backend'</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">        commandHtml</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `curl -X POST /api/v1/deploy`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E06C75">        outputHtml</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `{"status": "success", "message": "Deployment initiated..."}`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">        break</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">    case</span><span style="color:#98C379"> 'personal'</span><span style="color:#ABB2BF">:</span></span>
<span class="line"><span style="color:#E06C75">        commandHtml</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `cat ~/journal/</span><span style="color:#C678DD">${</span><span style="color:#E06C75">slug</span><span style="color:#C678DD">}</span><span style="color:#98C379">.md`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#E06C75">        outputHtml</span><span style="color:#56B6C2"> =</span><span style="color:#98C379"> `Reflecting on: "</span><span style="color:#C678DD">${</span><span style="color:#E06C75">description</span><span style="color:#C678DD">}</span><span style="color:#98C379">"`</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#C678DD">        break</span><span style="color:#ABB2BF">;</span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">    // ... other categories</span></span>
<span class="line"><span style="color:#ABB2BF">}</span></span></code></pre></div>

<h3>3. Build Integration</h3>
<p>The best part is that this is now part of my build pipeline. Whenever I run <code>npm run build:blog</code>, the script checks if a thumbnail exists for each post. If it&#39;s missing, it automatically generates a fresh one in milliseconds.</p>
<p>This means I never have to worry about assets again. I just write the markdown, tag it appropriately, and the system generates a beautiful, custom thumbnail that perfectly matches the content.</p>
<h2>The Result</h2>
<p>You&#39;re looking at it right now! The thumbnail for this post was generated by the very system it describes. It&#39;s a meta-example of automation serving creativity.</p>
<p>By treating design assets as code, I&#39;ve ensured 100% consistency across the blog while removing manual toil from my writing process.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Optimizing Blog Interactivity and UX]]></title>
      <description><![CDATA[A deep dive into recent improvements: fixing the blank screen issue, implementing collapsible tag filters, and debugging critical interactivity bugs.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/optimizing-blog-interactivity-and-ux</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/optimizing-blog-interactivity-and-ux</guid>
      <pubDate>Sun, 30 Nov 2025 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[web-development]]></category>
      <category><![CDATA[debugging]]></category>
      <category><![CDATA[ux]]></category>
      <category><![CDATA[javascript]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/optimizing-blog-interactivity-and-ux.png" />
      <content:encoded><![CDATA[<p>In this update, I&#39;ve focused on refining the user experience of this blog and resolving some critical issues that were hindering usability. Here&#39;s a breakdown of the changes and the technical challenges I overcame.</p>
<h2>1. Fixing the &quot;Blank Screen&quot; Issue</h2>
<p><strong>The Problem:</strong> Users were reporting that the blog content wasn&#39;t loading immediately, often resulting in a blank screen. This was caused by a &quot;loading overlay&quot; and a complex materialization animation that was intended to be a cool effect but ended up blocking content delivery.</p>
<p><strong>The Solution:</strong> I decided to prioritize performance and immediate content visibility. I completely removed the <code>loadingOverlay</code> HTML element and its associated CSS animations and JavaScript logic. Now, the blog content renders instantly upon page load, providing a much snappier experience.</p>
<h2>2. Collapsible Tag Filters</h2>
<p><strong>The Problem:</strong> As the number of tags grew, the &quot;filter by tag&quot; section on the homepage became cluttered and took up too much vertical space, pushing the actual blog posts down.</p>
<p><strong>The Solution:</strong> I implemented a collapsible tag filter section.</p>
<ul>
<li><strong>Default State:</strong> The section is now compact (limited to <code>85px</code> height) by default.</li>
<li><strong>Interactivity:</strong> A &quot;show more&quot; button allows users to expand the full list of tags if they wish to explore.</li>
<li><strong>Auto-Expansion:</strong> If you navigate to the blog with a specific tag selected (e.g., via a URL hash), the section automatically expands to show the active tag.</li>
</ul>
<h2>3. Debugging Critical Interactivity Bugs</h2>
<p><strong>The Problem:</strong> After implementing the collapsible tags, I encountered a frustrating issue where the &quot;show more&quot; button and the tag filters themselves were completely non-functional. The browser console was surprisingly quiet, showing no obvious errors.</p>
<p><strong>The Debugging Process:</strong></p>
<ol>
<li><strong>Initial Investigation:</strong> I suspected the event listeners weren&#39;t attaching correctly. I tried moving the script placement and using <code>DOMContentLoaded</code>, but the issue persisted.</li>
<li><strong>Deep Dive:</strong> I added extensive <code>console.log</code> statements, but none of them appeared. This suggested the script wasn&#39;t executing <em>at all</em>.</li>
<li><strong>The Breakthrough:</strong> I inspected the generated HTML and found a syntax error in the injected data. The build script was replacing <code>{{ allPostsData }}</code> with the JSON data, but the regex was too strict and didn&#39;t account for the spaces I had added in the template for readability (<code>{{ allPostsData }}</code>). This resulted in invalid JavaScript syntax, crashing the entire script block.</li>
</ol>
<p><strong>The Fix:</strong> I updated the build script&#39;s regex to be more flexible:</p>
<div class="code-block-wrap" data-lang="javascript"><span class="code-lang" aria-hidden="true">javascript</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">replace</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">/{{\s</span><span style="color:#D19A66">*</span><span style="color:#E06C75">allPostsData\s</span><span style="color:#D19A66">*</span><span style="color:#E06C75">}}/</span><span style="color:#C678DD">g</span><span style="color:#ABB2BF">, </span><span style="color:#E5C07B">JSON</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">stringify</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">sortedPosts</span><span style="color:#ABB2BF">))</span></span></code></pre></div>

<p>This simple change ensured the data was injected correctly, restoring all interactivity.</p>
<h2>4. Restoring Animations and Sound</h2>
<p>With the critical bugs squashed, I was able to re-enable the retro animations (like the &quot;digital rain&quot; effect) and the subtle sound effects that give this blog its unique cyberpunk aesthetic.</p>
<h2>Conclusion</h2>
<p>These updates highlight the importance of balancing aesthetic features with core usability. While animations are fun, they should never come at the cost of content accessibility. And sometimes, the trickiest bugs are hidden in the simplest places—like a few extra spaces in a template placeholder.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Refining Navigation and Branding: A Journey to Consistency]]></title>
      <description><![CDATA[How I unified the navigation experience across my portfolio, blog, and games, and refined the 'JJ' logo for better visibility.]]></description>
      <link>https://jeffreyjose07.is-a.dev/blog/refining-navigation-and-branding-a-journey-to-consistency</link>
      <guid isPermaLink="true">https://jeffreyjose07.is-a.dev/blog/refining-navigation-and-branding-a-journey-to-consistency</guid>
      <pubDate>Sun, 30 Nov 2025 00:00:00 GMT</pubDate>
      <author>jeffrey.jose07@gmail.com (Jeffrey Jose)</author>
      <dc:creator><![CDATA[Jeffrey Jose]]></dc:creator>
      <category><![CDATA[design]]></category>
      <category><![CDATA[branding]]></category>
      <category><![CDATA[ux]]></category>
      <category><![CDATA[frontend]]></category>
      <category><![CDATA[css]]></category>
      <media:thumbnail url="https://jeffreyjose07.is-a.dev/assets/thumbnails/refining-navigation-and-branding-a-journey-to-consistency.png" />
      <content:encoded><![CDATA[<p>Consistency is key in design. When I looked at my portfolio, I realized that while individual sections looked great, the overall experience felt disjointed. The blog had a different header than the main site, the games had their own unique navigation, and the logo—while cool—wasn&#39;t as visible as I wanted it to be.</p>
<p>Today, I embarked on a mission to unify the branding and navigation across the entire <code>jeffreyjose07.github.io</code> ecosystem. Here&#39;s how I did it.</p>
<h2>The &quot;JJ&quot; Monogram</h2>
<p>The first step was to refine the logo. I wanted something bold, geometric, and instantly recognizable. The previous iteration was a bit too subtle.</p>
<p>I iterated on a design featuring two thick, rounded J&#39;s. To add a bit of dynamism, I made the left &#39;J&#39; start slightly higher than the right one, and added a distinct dot on the right side. This created a balanced yet energetic monogram that works perfectly as a favicon and a header logo.</p>
<div class="code-block-wrap" data-lang="xml"><span class="code-lang" aria-hidden="true">xml</span><pre class="shiki one-dark-pro" style="background-color:#282c34;color:#abb2bf" tabindex="0"><code><span class="line"><span style="color:#ABB2BF">&#x3C;</span><span style="color:#E06C75">svg</span><span style="color:#D19A66"> xmlns</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"http://www.w3.org/2000/svg"</span><span style="color:#D19A66"> viewBox</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"0 0 512 512"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">defs</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;</span><span style="color:#E06C75">linearGradient</span><span style="color:#D19A66"> id</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"grad"</span><span style="color:#D19A66"> x1</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"0%"</span><span style="color:#D19A66"> y1</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"0%"</span><span style="color:#D19A66"> x2</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"100%"</span><span style="color:#D19A66"> y2</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"100%"</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">      &#x3C;</span><span style="color:#E06C75">stop</span><span style="color:#D19A66"> offset</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"0%"</span><span style="color:#D19A66"> style</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"stop-color:#7c3aed;stop-opacity:1"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">      &#x3C;</span><span style="color:#E06C75">stop</span><span style="color:#D19A66"> offset</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"100%"</span><span style="color:#D19A66"> style</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"stop-color:#3b82f6;stop-opacity:1"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">    &#x3C;/</span><span style="color:#E06C75">linearGradient</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;/</span><span style="color:#E06C75">defs</span><span style="color:#ABB2BF">></span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">  &#x3C;!-- Left J --></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">path</span><span style="color:#D19A66"> d</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"M180 120 V340 A60 60 0 0 1 60 340"</span><span style="color:#ABB2BF"> </span></span>
<span class="line"><span style="color:#D19A66">        fill</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"none"</span><span style="color:#D19A66"> stroke</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"url(#grad)"</span><span style="color:#D19A66"> stroke-width</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"80"</span><span style="color:#D19A66"> stroke-linecap</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"round"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">  &#x3C;!-- Right J --></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">path</span><span style="color:#D19A66"> d</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"M332 172 V340 A60 60 0 0 1 212 340"</span><span style="color:#ABB2BF"> </span></span>
<span class="line"><span style="color:#D19A66">        fill</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"none"</span><span style="color:#D19A66"> stroke</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"url(#grad)"</span><span style="color:#D19A66"> stroke-width</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"80"</span><span style="color:#D19A66"> stroke-linecap</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"round"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#7F848E;font-style:italic">  &#x3C;!-- Dot --></span></span>
<span class="line"><span style="color:#ABB2BF">  &#x3C;</span><span style="color:#E06C75">circle</span><span style="color:#D19A66"> cx</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"420"</span><span style="color:#D19A66"> cy</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"340"</span><span style="color:#D19A66"> r</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"40"</span><span style="color:#D19A66"> fill</span><span style="color:#ABB2BF">=</span><span style="color:#98C379">"url(#grad)"</span><span style="color:#ABB2BF"> /></span></span>
<span class="line"><span style="color:#ABB2BF">&#x3C;/</span><span style="color:#E06C75">svg</span><span style="color:#ABB2BF">></span></span></code></pre></div>

<p>With the new SVG in hand, I regenerated all the favicons—ICO, PNGs, and the Apple Touch Icon—ensuring that the site looks sharp on every device, from a 4K monitor to an iPhone home screen.</p>
<h2>Unifying the Navigation</h2>
<p>The biggest challenge was the navigation. My portfolio used a sleek &quot;Top Bar&quot; style, but the blog was still using an older &quot;Floating Pill&quot; design. Worse, the blog had a bug where duplicate menus would sometimes appear.</p>
<h3>The &quot;Top Bar&quot; Standard</h3>
<p>I decided to standardize on the &quot;Top Bar&quot; design. It&#39;s clean, professional, and provides easy access to all sections of the site without intruding on the content.</p>
<p>I updated <code>src/components/Navigation.tsx</code> to solidify this style for the main React app. Then, I turned my attention to the static parts of the site.</p>
<h3>Fixing the Blog</h3>
<p>The blog is statically generated using a custom Node.js script. I had to dive into <code>blog/templates/header.html</code> and completely rewrite the CSS and HTML to match the React component.</p>
<p><strong>Key Changes:</strong></p>
<ol>
<li><strong>Fixed Positioning:</strong> Moved from <code>top: 1.5rem</code> to <code>top: 0</code> with <code>width: 100%</code>.</li>
<li><strong>Backdrop Filter:</strong> Added a blur effect that activates on scroll, giving it that premium glassmorphism look.</li>
<li><strong>Responsive Design:</strong> Ensured the mobile menu works seamlessly with the new layout.</li>
</ol>
<h3>Updating the Games</h3>
<p>My games—Snake and Void Blocks—are standalone HTML pages. They also needed to join the family. I updated their headers to include the new logo and the standard Top Bar navigation. Now, whether you&#39;re playing a game, reading a blog post, or checking out my projects, the navigation remains consistent.</p>
<h2>The Result</h2>
<p>The result is a cohesive, polished experience. The new logo pops, the navigation feels familiar no matter where you are, and the site feels more like a single, unified product rather than a collection of disparate pages.</p>
<p>It&#39;s a small detail, but in UI/UX, details are everything.</p>
<hr>
<p><em>Check out the new logo in the header above!</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>