<?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[Kiragu Maina]]></title><description><![CDATA[Kiragu Maina]]></description><link>https://kiragumaina.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Kiragu Maina</title><link>https://kiragumaina.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 31 Aug 2026 01:58:16 GMT</lastBuildDate><atom:link href="https://kiragumaina.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[IndexNow will happily take a URL that does not exist yet. So check first.]]></title><description><![CDATA[MyPhotoAI serves 1,898 programmatic SEO pages, and none of them is server-rendered. Each is a committed HTML file. A worker writes the file into a git repo, updates the sitemap, pushes, and Cloudflare]]></description><link>https://kiragumaina.hashnode.dev/indexnow-will-happily-take-a-url-that-does-not-exist-yet-so-check-first</link><guid isPermaLink="true">https://kiragumaina.hashnode.dev/indexnow-will-happily-take-a-url-that-does-not-exist-yet-so-check-first</guid><category><![CDATA[SEO]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Devops]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kennedy Maina]]></dc:creator><pubDate>Sat, 29 Aug 2026 13:52:18 GMT</pubDate><content:encoded><![CDATA[<p>MyPhotoAI serves 1,898 programmatic SEO pages, and none of them is server-rendered. Each is a committed HTML file. A worker writes the file into a git repo, updates the sitemap, pushes, and Cloudflare Pages deploys the commit. Serving cost is close to zero, every page is a plain file for a crawler, and a rebuild is a diff.</p>
<p>That choice has two traps. This post is about both, and about the queue that grew out of them.</p>
<h2>Trap 1: Cloudflare Pages decides you are an SPA</h2>
<p>A Pages project with no top-level <code>404.html</code> is treated as a single-page application. Every unmatched path gets <code>index.html</code> with HTTP 200. Convenient for client-side routing. Fatal for observability, because a request for an asset chunk that no longer exists also gets <code>index.html</code> with HTTP 200.</p>
<p>For four months, <code>/tools-assets/</code> chunks that had been renamed by a build were being served as the app shell. The deploy was green. No monitor complained. No status code was wrong.</p>
<p>Adding <code>public/404.html</code> turned the project into a normal static site: unmatched paths are real 404s. That fixed the assets and created the opposite problem. Any app route that is not prerendered to its own file, and has no explicit rewrite, is now a hard 404 for real users. Nothing in the build catches that by default.</p>
<p>So the build catches it on purpose:</p>
<pre><code class="language-javascript">// scripts/check-spa-routes.mjs: fail the build if an App.tsx route would 404
const declared    = routesIn('src/App.tsx');            // &lt;Route path="..."&gt;
const prerendered = routesIn('scripts/prerender.mjs');  // ROUTES = [...]
const rewritten   = rules200In('public/_redirects');    // "/path /index.html 200"

// A rule covers a route if it matches exactly, or a splat rule is a prefix.
const covered = (route) =&gt;
  rewritten.some((rule) =&gt;
    rule.endsWith('/*') ? route.startsWith(rule.slice(0, -1)) : rule === route);

const missing = declared.filter((r) =&gt; !prerendered.includes(r) &amp;&amp; !covered(r));
if (missing.length) {
  console.error('Routes with no prerender and no 200 rewrite:', missing);
  process.exit(1);
}
</code></pre>
<p>Add a route and forget both, and the deploy fails instead of the page.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a92d2512a849b746bce11de/42997d95-2381-4876-a7eb-3e539e4650e4.png" alt="" style="display:block;margin:0 auto" />

<h2>Trap 2: IndexNow believes you</h2>
<p>IndexNow lets you tell Bing and Yandex about a URL instead of waiting for a crawl. The naive integration submits every generated URL the moment it is written. Two things go wrong:</p>
<ol>
<li><p>The page is not deployed yet. The commit is pushed, but Pages has not built it.</p>
</li>
<li><p>Because of trap 1, a status check proves nothing. Before <code>404.html</code> existed, the site returned 200 for every path, including pages that did not exist.</p>
</li>
</ol>
<p>So the queue does not check the status. It fetches the body and looks for the one thing only a real generated page contains: its own canonical tag.</p>
<pre><code class="language-typescript">async function checkLiveness(slug: string): Promise&lt;boolean&gt; {
  const res = await fetch(`${SITE_BASE}/${slug}`, {
    redirect: 'follow',
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) return false;
  const html = await res.text();
  return html.includes(`&lt;link rel="canonical" href="${SITE_BASE}/${slug}/"`);
}
</code></pre>
<h2>The queue</h2>
<p>The queue is a JSON file on a persistent volume with two lists, <code>pending</code> and <code>submitted</code>, and a cron every three hours:</p>
<pre><code class="language-typescript">export async function processIndexNowQueue() {
  const queue = loadQueue();
  const live: string[] = [];
  const remaining: PendingEntry[] = [];

  // 10 at a time, 10s timeout each
  for (const chunk of chunks(queue.pending, LIVENESS_CONCURRENCY)) {
    const results = await Promise.allSettled(chunk.map(async (e) =&gt; ({ e, ok: await checkLiveness(e.slug) })));
    for (const r of results) {
      if (r.status !== 'fulfilled') continue;
      const { e, ok } = r.value;
      if (ok) { live.push(e.slug); continue; }
      e.checkCount++;
      if (e.checkCount &gt;= MAX_CHECK_COUNT) {         // 56 checks, about 7 days
        logger.warn({ slug: e.slug }, 'never went live; dropped');
      } else {
        remaining.push(e);
      }
    }
  }

  if (live.length) {
    try {
      await submitToIndexNow(live.map((s) =&gt; `${SITE_BASE}/${s}`)); // batches of 10,000
      queue.submitted.push(...live.map((slug) =&gt; ({ slug, submittedAt: now() })));
    } catch (err) {
      // nothing is marked submitted on hope
      remaining.push(...queue.pending.filter((e) =&gt; live.includes(e.slug)));
    }
  }

  queue.pending = remaining;
  saveQueue(queue);
}
</code></pre>
<p>Four properties fall out of that:</p>
<ul>
<li><p><strong>Deduplicated.</strong> <code>enqueueSlugs</code> skips anything already pending or submitted.</p>
</li>
<li><p><strong>Bounded.</strong> A slug that never goes live is dropped after 56 checks with a warning, not retried forever.</p>
</li>
<li><p><strong>Honest on failure.</strong> If the POST fails, the live slugs go back to pending. The submitted list only ever contains URLs the API accepted.</p>
</li>
<li><p><strong>Batched.</strong> Up to 10,000 URLs per request, with three retries five seconds apart.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a92d2512a849b746bce11de/55b1fa7d-de14-4f5e-962e-5fb451e2935d.png" alt="" style="display:block;margin:0 auto" />

<h2>The part with no API</h2>
<p>There is no IndexNow removal call. When a page is unpublished, the worker does the only thing that works:</p>
<pre><code class="language-typescript">export function forceRecheck(slug: string): void {
  const queue = loadQueue();
  queue.submitted = queue.submitted.filter((e) =&gt; e.slug !== slug);  // forget it was submitted
  saveQueue(queue);
  enqueueSlugs([slug]);                                               // check it again
}
</code></pre>
<p>The next liveness pass fetches the tombstone, finds no canonical tag, counts a miss, and after 56 misses drops it. The search engines re-crawl a 404 on their own schedule. Slow, but it is the mechanism that exists.</p>
<h2>Takeaways</h2>
<ul>
<li><p>A static site with an SPA catch-all lies to every status check you write. Look for something only the real page has.</p>
</li>
<li><p>Never tell a search engine about a URL you have not fetched yourself.</p>
</li>
<li><p>Keep a <code>submitted</code> list that only the API's acceptance can write to.</p>
</li>
<li><p>Put the route check in the build, because a green deploy is not evidence.</p>
</li>
</ul>
<p>The other half of this engine, the AI quality gate that decides which pages exist at all and the deterministic per-slug layout variation that keeps them from looking like doorway pages, is in the full write-up: <a href="https://medium.com/@kennkyragu/how-i-built-a-1-898-page-programmatic-seo-engine-with-ai-quality-gates-static-html-and-indexnow-a2f5d80ec812">How I built a 1,898-page programmatic SEO engine with AI quality gates, static HTML and IndexNow</a>.</p>
<p>I build programmatic SEO pipelines and audit production deployments. <a href="mailto:kiragu@alkenacode.dev">kiragu@alkenacode.dev</a>, or a fixed-price engagement on <a href="https://contra.com/kiragu_maina_txoyol9a">Contra</a>. Case studies and more of this work: <a href="https://kiragu.alkenacode.dev">https://kiragu.alkenacode.dev</a></p>
]]></content:encoded></item><item><title><![CDATA[The payment row committed. The SMS never sent. Fixing the gap with a transactional outbox and SKIP LOCKED]]></title><description><![CDATA[Here is a bug that idempotency keys cannot fix.
An M-Pesa webhook arrives. The interceptor claims the key. The payment row inserts cleanly into Postgres. The transaction commits.
Then, before the subs]]></description><link>https://kiragumaina.hashnode.dev/the-payment-row-committed-the-sms-never-sent-fixing-the-gap-with-a-transactional-outbox-and-skip-locked</link><guid isPermaLink="true">https://kiragumaina.hashnode.dev/the-payment-row-committed-the-sms-never-sent-fixing-the-gap-with-a-transactional-outbox-and-skip-locked</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[backend]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[fintech]]></category><category><![CDATA[distributed systems]]></category><dc:creator><![CDATA[Kennedy Maina]]></dc:creator><pubDate>Sat, 29 Aug 2026 12:59:41 GMT</pubDate><content:encoded><![CDATA[<p>Here is a bug that idempotency keys cannot fix.</p>
<p>An M-Pesa webhook arrives. The interceptor claims the key. The payment row inserts cleanly into Postgres. The transaction commits.</p>
<p>Then, before the subscription end date is extended, before the receipt SMS goes out, before the RADIUS packet reaches the router, the container gets recreated by a deploy.</p>
<p>The ledger says paid. The router says no. A subscriber in Kisumu is offline with a receipt on their phone, and the ISP owner is calling.</p>
<p>This is the failure mode I spent the most time on in FyberPay, a billing and network platform for ISPs that runs eight payment rails behind one interface. The fix is old and boring and it works: a transactional outbox, drained by workers using <code>SELECT ... FOR UPDATE SKIP LOCKED</code>.</p>
<h2>The rule: nothing important happens inside the request</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a92d2512a849b746bce11de/c01b3c1c-1f44-4195-bbd4-559645e4f8a1.png" alt="" style="display:block;margin:0 auto" />

<p>The request handler is allowed to do exactly one thing with side effects: write to the database. Everything that talks to the outside world (SMS gateway, email, the router's RADIUS port) is recorded as an <em>intent</em> in the same transaction, and executed later by something that can be retried.</p>
<pre><code class="language-typescript">// payments.service.ts (simplified)
await this.db.transaction(async (tx) =&gt; {
  const payment = await tx.payments.insert({ gateway, receiptNumber, amount, subscriberId });

  await tx.outboxEvents.insert({
    type: 'PAYMENT_CONFIRMED',
    aggregateId: payment.id,
    payload: { subscriberId, amount, tenantId },
    status: 'pending',
  });
});
// COMMIT. Both rows exist, or neither does.
</code></pre>
<p>That "or neither does" is the entire point. There is no state of the world where the payment exists and the intent to act on it does not.</p>
<h2>The claim query</h2>
<p>Workers poll the outbox. The naive version, <code>SELECT * FROM outbox_events WHERE status = 'pending' LIMIT 1</code>, falls apart the moment you run two workers: both read the same row, both send the SMS.</p>
<p>Postgres has a three-word fix:</p>
<pre><code class="language-sql">UPDATE outbox_events
SET status = 'claimed', claimed_at = now(), attempts = attempts + 1
WHERE id = (
  SELECT id FROM outbox_events
  WHERE status = 'pending' AND next_attempt_at &lt;= now()
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;
</code></pre>
<p><code>FOR UPDATE</code> locks the row the subquery picks. <code>SKIP LOCKED</code> tells every other worker to ignore rows someone else already holds and take the next one. Ten workers hit the table at the same instant, ten different rows come back, nobody blocks, nobody double-sends.</p>
<p>The <code>UPDATE ... RETURNING</code> wrapper matters too: claiming and reading are one statement, so there is no window where a worker has read a row but not yet marked it.</p>
<h2>What runs off the event</h2>
<p>In FyberPay a <code>PAYMENT_CONFIRMED</code> event fans out to three listeners:</p>
<ul>
<li><p><strong>Subscription extension.</strong> Credit-aware, so a partial payment does the arithmetic instead of failing.</p>
</li>
<li><p><strong>Receipt notification.</strong> SMS or email, through whichever of nine SMS gateways the tenant configured.</p>
</li>
<li><p><strong>RADIUS Change-of-Authorization.</strong> A hand-rolled UDP packet with an MD5 authenticator, sent to the MikroTik NAS so the subscriber is online within seconds instead of at their next session.</p>
</li>
</ul>
<p>Each listener is independent. If the SMS gateway is down, the router still gets its packet.</p>
<h2>When a worker dies mid-way</h2>
<p>This is the case the outbox exists for, so look at it closely.</p>
<pre><code class="language-plaintext">worker A claims row 9931  (status = claimed, claimed_at = 10:41:03)
worker A sends the SMS       ok
worker A opens a UDP socket to the NAS
worker A is killed           (deploy recreated the container)
</code></pre>
<p>Row 9931 is now <code>claimed</code> with a <code>claimed_at</code> that is getting older. A reaper job runs every minute:</p>
<pre><code class="language-sql">UPDATE outbox_events
SET status = 'pending', next_attempt_at = now() + (interval '30 seconds' * attempts)
WHERE status = 'claimed' AND claimed_at &lt; now() - interval '2 minutes';
</code></pre>
<p>The row goes back in the queue with exponential backoff. Another worker picks it up. The SMS goes out a second time.</p>
<p>Yes, a second time. And that is fine, because of a rule that is easy to state and easy to forget:</p>
<blockquote>
<p>The outbox gives you at-least-once. Every listener has to be safe to run twice.</p>
</blockquote>
<p>A duplicate SMS is a minor annoyance. A duplicate CoA packet for the same session is a no-op on the router. A duplicate subscription extension would be a real bug, which is why the extension listener checks the payment id it has already applied before it moves the end date. The outbox does not make your side effects idempotent; it makes sure they eventually run, and it forces you to make them idempotent yourself.</p>
<h2>What the ledger and the outbox are not</h2>
<p>The outbox is layer three of three. It sits underneath two layers that keep the <em>ledger</em> exactly-once:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a92d2512a849b746bce11de/6a84dc02-3908-4506-85c3-18e021e76e03.png" alt="" style="display:block;margin:0 auto" />

<ol>
<li><p>A Redis interceptor keyed by principal, tenant, route, body hash and the client's idempotency key, claimed with <code>SET NX</code>.</p>
</li>
<li><p>A <code>UNIQUE (gateway, receipt_number)</code> constraint on the payments table, so a replayed receipt rolls back before it credits anything.</p>
</li>
</ol>
<p>The outbox never has to be exactly-once precisely because those two layers already are. Redis is speed. Postgres is truth. The outbox is durability.</p>
<h2>Numbers, for scale</h2>
<p>In production this runs as 75 outbox listeners across 49 queue processors in 17 Docker services, on a schema of 263 hand-written SQL migrations with about 8,200 automated tests behind it.</p>
<h2>If you take one thing</h2>
<p>Do not send the SMS in the request handler. Write a row. Let a worker send the SMS. Give the worker <code>SKIP LOCKED</code> and a reaper, and make every listener safe to run twice.</p>
<p>The interceptor and the unique-constraint layers, and the circuit breakers that keep a wrong PIN from looking like an outage, are in the full write-up: <a href="https://medium.com/@kennkyragu/building-3-layer-idempotency-and-webhook-resilience-across-8-payment-gateways-m-pesa-paystack-and-f8eb85bea1e4">Building 3-layer idempotency and webhook resilience across 8 payment gateways</a>.</p>
<p>I build and audit payment backends like this one. <a href="mailto:kiragu@alkenacode.dev">kiragu@alkenacode.dev</a>, or a fixed-price engagement on <a href="https://contra.com/kiragu_maina_txoyol9a">Contra</a>. Case studies and more of this work: <a href="https://kiragu.alkenacode.dev">https://kiragu.alkenacode.dev</a></p>
]]></content:encoded></item></channel></rss>