<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Breath First Search]]></title><description><![CDATA[Breath First Search]]></description><link>https://sanjitsaluja.com</link><generator>RSS for Node</generator><lastBuildDate>Thu, 09 Apr 2026 14:56:05 GMT</lastBuildDate><atom:link href="https://sanjitsaluja.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Built a Production MCP Server in 10 Hours. The Real Lever Was Context Engineering.]]></title><description><![CDATA[A few weeks ago I was on call. Busy week — a fair number of pages, a couple of incidents, the usual pressure of being the person who has to figure out what's broken right now. My debugging loop was Cu]]></description><link>https://sanjitsaluja.com/i-built-a-production-mcp-server-in-10-hours-the-real-lever-was-context-engineering</link><guid isPermaLink="true">https://sanjitsaluja.com/i-built-a-production-mcp-server-in-10-hours-the-real-lever-was-context-engineering</guid><dc:creator><![CDATA[Sanjit Saluja]]></dc:creator><pubDate>Mon, 16 Mar 2026 23:10:04 GMT</pubDate><content:encoded><![CDATA[<p>A few weeks ago I was on call. Busy week — a fair number of pages, a couple of incidents, the usual pressure of being the person who has to figure out what's broken right now. My debugging loop was Cursor → TablePlus → Cursor → TablePlus on repeat. Write a query, copy it, paste it into TablePlus, run it, copy the results, paste them back into Cursor so the AI could reason about them, get a follow-up query, copy that, switch back — repeat.</p>
<p>I was spending more time on clipboard management than on actually understanding the problem.</p>
<p>By day three, I started building an MCP server on the side: a tool that gives AI agents direct, read-only access to our production Postgres replicas. Cloudflare tunnel auth, IAM token management, PII scrubbing, four databases, audit logging. I worked on it an hour or two a day between pages. By the end of the week it was in final review: a small set of audited, replica-only query tools with PII protection that let Cursor inspect production data directly — no manual token generation, no copy-pasting between apps. About 10 hours of total effort — and the reason it went that fast wasn't the model. It was that I stopped treating context as chat history and started treating it as a system to design.</p>
<hr />
<h2>Context Engineering</h2>
<p>For a while, my default approach to building with AI was a single long conversation. Describe the feature, iterate, keep going. It works for small things. But once a feature stops fitting in one sitting, things go sideways — the model forgets earlier decisions, contradicts itself, confidently breaks things I already fixed. Past a certain thread length, recent information is just louder than old information.</p>
<p>By context engineering, I don't mean writing clever prompts. I mean deliberately controlling three things: what the model sees, what it doesn't see, and what state lives outside the conversation. It's closer to interface design than prompt engineering.</p>
<p>Here's roughly how I partition it:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What I give it</th>
<th>What I leave out</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Spec</td>
<td>My idea + one question at a time</td>
<td>Full design upfront</td>
<td>I want behavior before implementation details</td>
</tr>
<tr>
<td>Task breakdown</td>
<td>The complete spec</td>
<td>The codebase</td>
<td>Otherwise it reverse-engineers a plan from the current codebase instead of the intended system</td>
</tr>
<tr>
<td>Execution</td>
<td>Spec + current task + what's done so far</td>
<td>All other tasks</td>
<td>Prevents speculative coding and keeps changes local</td>
</tr>
</tbody></table>
<hr />
<h2>How This Played Out on the MCP Server</h2>
<h3>Step 1: Spec Interactively</h3>
<p>I opened a conversation with a reasoning model and gave it one instruction:</p>
<p><em>Spec Development Prompt</em></p>
<pre><code>We're going to develop a detailed spec together in three phases.

## Phase 1: Clarify requirements and constraints

Goal: Build a shared understanding of what we're building and why.

Rules:
- Ask one question at a time. Each builds on my previous answer.
- Do not move to the next question until I've responded.
- Cover: user-facing behavior, data model, integration points, non-functional requirements.
- When I'm vague, ask me to be specific. When I give you a solution, ask me what problem it solves.

**Exit gate:** Summarize your understanding as a bulleted requirements list. Do not proceed until I confirm.

## Phase 2: Challenge the architecture

Goal: Pressure-test the design before we commit.

Behave like a skeptical staff engineer reviewing a design doc. Do not accept my initial architecture at face value. Specifically:
- When you detect an assumption, ask what problem it solves and whether there's a simpler alternative.
- Before converging, ensure we've explored at least one credible alternative.
- Call out irreversible choices and unstated assumptions explicitly.
- Probe for gaps in: retry semantics, failure modes, state lifecycle, observability.

**Exit gate:** List the key decisions made, alternatives rejected with rationale, and open questions. Do not proceed until I confirm.

## Phase 3: Produce the spec

Goal: A developer-ready spec I can hand to a reviewer or work from directly.

Output format:
1. **Overview** — what and why (2-3 sentences)
2. **Requirements** — confirmed from Phase 1
3. **Architecture** — components, data flow, integration points
4. **Key decisions** — what we decided, why, and what we rejected
5. **Data model** — tables, fields, relationships
6. **API surface** — endpoints, events, or interfaces exposed/consumed
7. **Edge cases and failure modes**
8. **Open questions**

---

Begin Phase 1. Ask the first question.

Here's the idea: [IDEA]
</code></pre>
<p>The one-question-at-a-time constraint has become my favorite trick. It forces the model to integrate each answer before moving on. It also forces me to think instead of dumping half-formed ideas into the thread.</p>
<p>I walked in with a rough idea: wrap our existing Cloudflare tunnel + AWS IAM auth flow behind MCP tools so AI agents can query production read replicas without manual setup. Two hours of back-and-forth, and two things happened that I don't think would have happened if I'd just started coding.</p>
<p><strong>The model challenged my architecture.</strong> My initial design proposed shelling out to CLI tools — running <code>aws rds generate-db-auth-token</code> as a subprocess, parsing stdout, piping results around. I'd been doing it manually during on-call, so automating the same commands felt natural. The model pushed back: why spawn processes and parse strings when <code>@aws-sdk/rds-signer</code> gives you <code>Signer.getAuthToken()</code> directly? Testable, no string parsing, respects the credential provider chain natively.</p>
<p>I didn't just take its word for it — I asked it to walk through the tradeoffs. But the conversation surfaced a better design than what I walked in with.</p>
<p><strong>Deep domain knowledge showed up where I didn't expect it.</strong> The model was fluent in IAM auth token mechanics, RDS replica routing, Cloudflare Access tunnel lifecycle, Postgres session-level guardrails. It suggested <code>default_transaction_read_only=on</code> at the session level as defense-in-depth on top of DB role permissions. It flagged that IAM tokens expire after 15 minutes and proposed reconnect-on-auth-failure instead of timer-based refresh — simpler, and it handles edge cases like clock skew naturally. None of this was prompted; it emerged because the spec format gave the model room to reason about the full problem.</p>
<p>By the end I had a structured document: problem statement, tool interface, connection model, module breakdown, security constraints, error codes, and explicit open decisions. Those suggestions didn't surface because of a magical prompt. They surfaced because the one-at-a-time format let the model reason about the full problem before I pushed it into implementation.</p>
<h3>Step 2: Task Breakdown</h3>
<p>I use <a href="https://dex.rip">Dex</a> for task management, but the tool matters less than the property: tasks live as files on the local filesystem rather than in a cloud board or a chat thread. This turned out to matter more than I expected.</p>
<p>I fed the finished spec into Dex and broke it into tasks scoped to single modules: target registry with replica-only validation, Cloudflare tunnel lifecycle manager, IAM token generation with reconnect-on-failure, session-level Postgres guardrails, audit logging. Each independently mergeable, with explicit dependencies.</p>
<p>The key thing about tasks on disk: they persist between sessions without me having to carry them in a conversation thread. When I opened a new coding session the next day between pages, I pointed the model at the spec and one Dex task file — not yesterday's chat history. The model couldn't build ahead because it only saw one task. It didn't need to remember what I did yesterday. The state lived on the filesystem, not in the context window.</p>
<p>This is the difference between a long thread and externalized state. A thread loses fidelity as it grows. A task file on disk is the same on day one and day five.</p>
<h3>Step 3: Execution</h3>
<p>Each coding session got a three-part context: spec for intent, current task for scope, dependency state for boundaries. For example: target registry done, tunnel manager done, audit logger pending — so the model knew what interfaces already existed and what it should not invent.</p>
<p>The first pass produced working code with the wrong structure — everything in a monolithic <code>index.ts</code> and an ever-growing <code>types.ts</code>. I've seen this enough to think of it as a default AI code smell: a few general-purpose files that just keep expanding.</p>
<p>I opened a fresh session with the spec's module breakdown as explicit context: "here's the target architecture — target-registry, access-adapter, client-adapter, query-orchestrator, audit-logger. Refactor into this structure." Clean separation on the second pass.</p>
<p>The model didn't write bad code because it's bad at code. It wrote monolithic code because nothing in its context told it not to.</p>
<h3>Step 4: Review</h3>
<p>Asking the model that wrote the code to also review it is like reviewing your own PR. So I used a separate model for code review — one better at reading and critiquing than generating. This reflects something I've come to believe more broadly: generation and critique are different tasks and benefit from different contexts, sometimes different tools.</p>
<p>The review caught edge cases in tunnel process cleanup during shutdown, validated that Postgres guardrails were applied on every connection (not just the first), and checked consistency across the error model.</p>
<p>Total: about 10 hours across the week. Two hours on the spec. A few hours building across sessions. An hour each on code review and security review. Fifteen minutes of final QA.</p>
<hr />
<h2>Where This Doesn't Work</h2>
<p>I want to be honest about the limits.</p>
<p><strong>The model assumed an architecture I hadn't decided on.</strong> On a previous project, I got several tasks deep before realizing the model had picked an approach that closed off what I actually wanted. Had to revert and restart. The fix I've landed on: surface architectural decisions as explicit open questions in the spec and flag them before unblocking dependent tasks. The MCP build caught this early — but only because I'd been burned before.</p>
<p><strong>First-pass code quality was mediocre.</strong> The MCP build had this. If nothing in context specifies module boundaries, code piles up. I now include the target architecture explicitly in the execution context.</p>
<p><strong>Spec too big, tasks explode.</strong> Twenty-plus tasks with blurry boundaries usually means the feature isn't one feature. More than eight to ten has been my signal to split the spec.</p>
<p><strong>The model inherited the local testing culture.</strong> In parts of the codebase where tests were sparse, it treated tests as optional. Models mirror visible norms. I've started adding "each task includes tests for the behavior it introduces" to the task breakdown, and calling out low coverage at the start of coding sessions.</p>
<p>There are also categories of work where I don't think this workflow fits: greenfield exploration where requirements are genuinely unknown, large cross-team architectural decisions that need human alignment more than AI output, or domains where correctness depends on deep hidden context that can't easily be written into a spec. This is a workflow for building well-understood features faster. It's not a replacement for figuring out what to build.</p>
<hr />
<h2>What I Took Away</h2>
<p>The biggest shift for me was realizing that reliability doesn't come from asking the model to "remember." It comes from deciding what belongs in the context window and what belongs outside it. Once I started treating context as a designed interface instead of accumulated chat history, multi-day AI-assisted work got much better.</p>
<p>The model matters. But on multi-day engineering work, context has been the bigger lever.</p>
<hr />
<p><em>I'm still iterating on this. If you've found approaches that work for keeping context tight on multi-day features — especially across service boundaries — I'd like to hear about them.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Cut Page Load Time by 90%]]></title><description><![CDATA[I built Zugzwang, a browser-based chess puzzle app powered by Stockfish WASM. It worked, but the initial load was brutal—nearly 39 seconds on a throttled 4G connection before the board was even visibl]]></description><link>https://sanjitsaluja.com/how-i-cut-page-load-time-by-90</link><guid isPermaLink="true">https://sanjitsaluja.com/how-i-cut-page-load-time-by-90</guid><category><![CDATA[performance]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[wasm]]></category><category><![CDATA[stockfish]]></category><category><![CDATA[chess]]></category><dc:creator><![CDATA[Sanjit Saluja]]></dc:creator><pubDate>Sun, 15 Feb 2026 22:57:02 GMT</pubDate><content:encoded><![CDATA[<p>I built <a href="https://zugzwang.me">Zugzwang</a>, a browser-based chess puzzle app powered by Stockfish WASM. It worked, but the initial load was brutal—nearly <strong>39 seconds</strong> on a throttled 4G connection before the board was even visible. Slower devices showed even worse numbers.</p>
<p>If you're shipping WASM or heavy client-side dependencies, you've probably hit this wall.</p>
<p>What was the holdup? Everything was on the critical path. A modal the user hadn't opened yet, CSS for themes the user hadn't selected, and a 7.3 MB chess engine the user didn't need until their first move.</p>
<p>Three commits later, board-visible time dropped from <strong>38.7s to 3.7s (p50)</strong>. Here's exactly what I did.</p>
<hr />
<h2>Defining the Metrics</h2>
<p>Before making any changes, I needed clear targets.</p>
<p><strong>Board-visible time</strong> is my primary metric: the moment the chessboard element (<code>.ui-board-root</code>) first renders with a non-zero layout box. This is custom instrumentation via Playwright's <code>MutationObserver</code>, and it directly measures "when can the user see the puzzle and start thinking."</p>
<p><strong>LCP (Largest Contentful Paint)</strong> is the standard Web Vital that captures when the largest element finishes rendering. In this app, LCP closely tracks board-visible time but includes additional paint work. I used LCP as a supporting metric via <code>PerformanceObserver</code>.</p>
<p><strong>FCP (First Contentful Paint)</strong> measures when <em>any</em> content first appears. This stayed relatively stable across optimizations—the big wins came from what happened <em>after</em> first paint.</p>
<p>Cloudflare supports LCP and FCP out of the box and both Chrome DevTools and Lighthouse also report LCP and FCP. For all measurements, I used the throttled 4G simulation (1.6 Mbps down, 750 ms RTT) on the same machine with Playwright automation.</p>
<hr />
<h2>Commit 1: Lazy-Load the Menu Modal</h2>
<p><strong>Problem:</strong> The <code>MenuModal</code> component (stats, settings, theme pickers) was statically imported in the main app. Its code shipped in the main bundle and was parsed on every page load, even though most users don't open the menu immediately.</p>
<p><strong>Fix:</strong> React's <code>lazy()</code> + <code>Suspense</code>.</p>
<pre><code class="language-ts">const LazyMenuModal = lazy(async () =&gt; {
  const module = await import("@/components/MenuModal");
  return { default: module.MenuModal };
});
</code></pre>
<p>A <code>shouldRenderMenu</code> state gate ensures the component tree doesn't even mount <code>&lt;Suspense&gt;</code> until the user first opens the menu. This avoids the lazy chunk being prefetched by React before it's wanted:</p>
<pre><code class="language-ts">const [shouldRenderMenu, setShouldRenderMenu] = useState(false);

useEffect(() =&gt; {
  if (isMenuOpen) setShouldRenderMenu(true);
}, [isMenuOpen]);
</code></pre>
<p><strong>Result:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
<th>Delta</th>
</tr>
</thead>
<tbody><tr>
<td>Main JS (initial route)</td>
<td>427.60 kB</td>
<td>415.20 kB</td>
<td>-12.40 kB (-2.9%)</td>
</tr>
<tr>
<td>Main JS gzip</td>
<td>133.74 kB</td>
<td>130.99 kB</td>
<td>-2.75 kB (-2.1%)</td>
</tr>
<tr>
<td>MenuModal chunk (loaded on demand)</td>
<td>—</td>
<td>16.88 kB (4.70 kB gzip)</td>
<td>—</td>
</tr>
</tbody></table>
<p>This change barely moved the needle. But it established the mental model for the bigger wins: if a component is behind a user interaction, it doesn't belong in your initial bundle.</p>
<hr />
<h2>Commit 2: Load Only the Selected Board and Piece Styles</h2>
<p><strong>Problem:</strong> The app ships multiple chessboard themes (blue, brown, gray, green) and piece sets. All of them were statically imported as CSS—meaning every user downloaded every theme on first load, even though they can only see one at a time.</p>
<p><strong>Fix:</strong> A tiny style loader (<code>chessground-style-loader.ts</code>) that dynamically imports only the active theme's CSS:</p>
<pre><code class="language-ts">const boardThemeLoaders: Record&lt;string, () =&gt; Promise&lt;unknown&gt;&gt; = {
  blue:  () =&gt; import("@/styles/chessground-board-theme-blue.css"),
  brown: () =&gt; import("@/styles/chessground-board-theme-brown.css"),
  // ...
};

const loadedBoardThemes = new Set&lt;string&gt;();
const loadingBoardThemes = new Map&lt;string, Promise&lt;void&gt;&gt;();

async function loadStyleOnce(
  name: string,
  loaded: Set&lt;string&gt;,
  loading: Map&lt;string, Promise&lt;void&gt;&gt;,
  loaders: Record&lt;string, () =&gt; Promise&lt;unknown&gt;&gt;
) {
  if (loaded.has(name)) return;
  if (loading.has(name)) return loading.get(name);

  const promise = loaders[name]()
    .then(() =&gt; { loaded.add(name); })
    .finally(() =&gt; { loading.delete(name); });

  loading.set(name, promise);
  return promise;
}
</code></pre>
<p>The <code>Board</code> component calls <code>ensureBoardThemeStyles(theme)</code> and <code>ensurePieceSetStyles(pieceSet)</code> in <code>useEffect</code> hooks whenever the theme prop changes. The <code>loaded</code> set and <code>loading</code> map prevent duplicate requests.</p>
<p><strong>UX guardrail:</strong> To avoid a flash of unstyled board, I load the user selected theme CSS synchronously in the initial bundle—only <em>alternative</em> themes load dynamically.</p>
<p>I also split the monolithic CSS file into per-theme files using CSS custom properties and gradients, which gave the bundler clean split points.</p>
<p><strong>Result:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
<th>Delta</th>
</tr>
</thead>
<tbody><tr>
<td>Initial app CSS</td>
<td>159.51 kB</td>
<td>81.15 kB</td>
<td>-78.36 kB (-49.1%)</td>
</tr>
<tr>
<td>Initial app CSS gzip</td>
<td>33.02 kB</td>
<td>12.92 kB</td>
<td>-20.10 kB (-60.9%)</td>
</tr>
</tbody></table>
<p><strong>A 61% reduction in CSS over the wire.</strong> CSS is render-blocking by default—the browser won't paint anything until it's finished parsing all linked stylesheets. Cutting the CSS payload in half directly accelerated first paint.</p>
<hr />
<h2>Commit 3: Decouple Puzzle Render From Stockfish Startup</h2>
<p>This was the big one. The app loads the Stockfish AI as WASM to simulate computer moves and provide user move feedback.</p>
<p><strong>Problem:</strong> The app waited for the WASM to initialize before rendering the puzzle board. Stockfish's WASM binary is <strong>~7.3 MB</strong>. On a slow connection, the user stared at a loading spinner for 36+ seconds before seeing a single chess piece.</p>
<p>But here's the thing: the user needs to <em>see</em> the board right away. The engine is only needed to <em>validate</em> moves. That's a meaningfully different moment in the user flow.</p>
<h3>Deep Dive</h3>
<p>These waterfall charts show exactly what changed. In the baseline, the board couldn't render until the 36-second WASM download completed:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771195655051/4b198f9e-a03f-43c8-ab5e-258380b0a061.png" alt="" style="display:block;margin:0 auto" />

<p><em>Baseline: Board visibility blocked on Stockfish WASM (36.28s)</em></p>
<p>After decoupling, the board renders in ~3.7s while WASM downloads in the background:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771195694247/f78addf8-7a4f-4b18-a020-b7195f656aee.png" alt="" style="display:block;margin:0 auto" />

<p><em>Current: Board visible at 3.7s; WASM download continues in parallel</em></p>
<p>The striped bar in the current waterfall shows Stockfish still in-flight when the board becomes visible. That's the critical path fix visualized.</p>
<p><strong>Fix:</strong> I restructured the initialization sequence so the puzzle data and board render proceed independently of the engine:</p>
<ol>
<li><p><strong>Fetch puzzles and render immediately.</strong> The puzzle JSON is small (~1.4s to load on throttled 4G). Once it arrives, mount the board.</p>
</li>
<li><p><strong>Initialize Stockfish in the background.</strong> A <code>stockfishRef</code> holds the engine instance; a <code>createPuzzleStrategy()</code> function lazily initializes it on the first move that actually requires engine evaluation.</p>
</li>
<li><p><strong>Show engine state only when relevant.</strong> A new <code>isAwaitingEngineMove</code> flag drives a "Loading engine..." indicator in <code>PuzzleInfo</code>, but only when the user has made a move and the engine hasn't finished loading. Before that, the user sees the board and can think about the position.</p>
</li>
</ol>
<pre><code class="language-ts">// Lazy engine initialization — only when we actually need evaluation
function createPuzzleStrategy() {
  if (stockfishRef.current) return engineStrategy(stockfishRef.current);

  beginEngineWait();
  return initStockfish()
    .then(engine =&gt; {
      stockfishRef.current = engine;
      endEngineWait();
      return engineStrategy(engine);
    })
    .catch(() =&gt; {
      endEngineWait();
      return solutionBasedStrategy(); // graceful fallback
    });
}
</code></pre>
<h3>Graceful Degradation</h3>
<p>The fallback to <code>solutionBasedStrategy()</code> is a deliberate architectural choice. If Stockfish fails to load—network timeout, WASM unsupported, whatever—the app remains functional. It checks moves against the known solution line instead of running a full evaluation. Users lose engine analysis for alternative lines, but they can still solve puzzles. This matters for offline scenarios and older devices where WASM might be flaky.</p>
<p>I also added tests for the loading states (<code>PuzzleInfo.test.tsx</code>) covering the three key scenarios: active play, engine loading during validation, and puzzle completion.</p>
<p><strong>Result:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Baseline p50</th>
<th>Current p50</th>
<th>Delta</th>
</tr>
</thead>
<tbody><tr>
<td>Board visible</td>
<td>38,669 ms</td>
<td>3,673 ms</td>
<td>-34,996 ms (-90.5%)</td>
</tr>
<tr>
<td>LCP</td>
<td>38,688 ms</td>
<td>4,488 ms</td>
<td>-34,200 ms (-88.4%)</td>
</tr>
<tr>
<td>FCP</td>
<td>2,376 ms</td>
<td>2,268 ms</td>
<td>-108 ms (-4.5%)</td>
</tr>
</tbody></table>
<p>The board now renders as soon as the puzzle data arrives. Stockfish loads in the background. The user starts thinking about the position <strong>35 seconds earlier</strong>.</p>
<hr />
<h2>What I Considered But Didn't Ship</h2>
<p>I also evaluated SSR, WASM streaming, and service workers.</p>
<p><strong>SSR didn't address this bottleneck.</strong> It could pre-render markup, but Stockfish still downloads and initializes on the client regardless of how the markup arrives. The dominant cost—7.3 MB of WASM—remains unchanged. SSR would add architectural complexity without fixing the actual critical path.</p>
<p><strong>WASM streaming was already present.</strong> Stockfish's runtime uses <code>instantiateStreaming</code> with a fallback path. I tested forcing non-streaming, and it barely moved the needle—engine readiness changed by ~35ms. Not worth pursuing.</p>
<p><strong>Service workers are the one meaningful follow-up.</strong> They won't help cold-load board visibility, but they should dramatically reduce repeat-visit engine startup by caching the WASM binary. I'll try this next.</p>
<p>This investigation reinforced the core lesson: measure before you architect. SSR and streaming sounded like obvious wins until I traced the actual bottleneck.</p>
<hr />
<h2>Summary</h2>
<table>
<thead>
<tr>
<th><strong>Change</strong></th>
<th><strong>What Moved Off the Critical Path</strong></th>
<th><strong>Key Savings</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Lazy-load menu modal</td>
<td>16.88 kB of JS (modal code)</td>
<td>-2.9% initial JS</td>
</tr>
<tr>
<td>Dynamic theme loading</td>
<td>Unused CSS themes and piece sets</td>
<td>-61% initial CSS (gzip)</td>
</tr>
<tr>
<td>Decouple Stockfish</td>
<td>7.3 MB WASM binary</td>
<td>-90.5% board-visible time</td>
</tr>
</tbody></table>
<h3>Full Distribution Results</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Baseline p50</th>
<th>Current p50</th>
<th>Delta</th>
<th>Baseline p95</th>
<th>Current p95</th>
</tr>
</thead>
<tbody><tr>
<td>Board visible</td>
<td>38,669 ms</td>
<td>3,673 ms</td>
<td>-90.5%</td>
<td>38,687 ms</td>
<td>3,675 ms</td>
</tr>
<tr>
<td>LCP</td>
<td>38,688 ms</td>
<td>4,488 ms</td>
<td>-88.4%</td>
<td>38,707 ms</td>
<td>4,495 ms</td>
</tr>
<tr>
<td>FCP</td>
<td>2,376 ms</td>
<td>2,268 ms</td>
<td>-4.5%</td>
<td>2,384 ms</td>
<td>2,268 ms</td>
</tr>
</tbody></table>
<h3>Methodology</h3>
<p>All measurements from 7 cold-load runs per revision using Playwright with Chromium. Network throttled via CDP to simulate Slow 4G (1.6 Mbps down, 0.75 Mbps up, 750 ms RTT). Fresh browser context per run with cache disabled. Board-visible time measured via <code>MutationObserver</code> watching for <code>.ui-board-root</code> with non-zero layout box. LCP and FCP captured via <code>PerformanceObserver</code>.</p>
<hr />
<h2>Takeaways</h2>
<p><strong>Audit your critical path, not your bundle size.</strong> Bundle size is a proxy metric. The real question is: what does the user need to see and interact with <em>right now</em>, and what can wait? In this case, the largest payload (Stockfish WASM) wasn't even render-blocking by nature—I had just wired it up that way.</p>
<p><strong>CSS is the silent blocker.</strong> JavaScript gets all the performance discourse, but CSS is render-blocking by default. Shipping 160 kB of CSS when the user only needs 80 kB means the browser is parsing themes the user will never see before it paints anything.</p>
<p><strong>Lazy loading is a spectrum.</strong> <code>React.lazy()</code> is the obvious tool, but the same principle applies to CSS, WASM, and any asset. The pattern is always the same: identify the trigger (user interaction, route change, first move), load the asset at that trigger, and handle the loading state gracefully.</p>
<p><strong>Measure before you architect.</strong> The "obvious" optimizations (SSR, streaming) weren't the right levers for this problem. The bottleneck wasn't server rendering or download efficiency—it was blocking on resources that weren't needed yet.</p>
<hr />
<p><em>The full source is at</em> <a href="https://github.com/sanjitsaluja/zugzwang-puzzle-trainer"><em>github.com/sanjitsaluja/zugzwang-puzzle-trainer</em></a><em>. If you're building with heavy WASM dependencies, I'd be curious to hear how you've handled the initialization tradeoff.</em></p>
]]></content:encoded></item></channel></rss>