<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nan</title>
    <description>The latest articles on DEV Community by Nan (@eye_java_420f1faa10ae8b86).</description>
    <link>https://dev.to/eye_java_420f1faa10ae8b86</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4142919%2F40b26578-b7dd-4d20-85a0-379f7587396f.png</url>
      <title>DEV Community: Nan</title>
      <link>https://dev.to/eye_java_420f1faa10ae8b86</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eye_java_420f1faa10ae8b86"/>
    <language>en</language>
    <item>
      <title>My Unattended Job Ran on Time and Did Nothing</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:54:46 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/my-unattended-job-ran-on-time-and-did-nothingmy-unattended-job-ran-on-time-and-did-nothing-aef</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/my-unattended-job-ran-on-time-and-did-nothingmy-unattended-job-ran-on-time-and-did-nothing-aef</guid>
      <description>&lt;p&gt;&lt;em&gt;This post describes the behavior of specific tool versions on my machine — macOS with bash 3.2 and the Claude Code CLI as of October 2026. Argument-parsing details change; verify against your own versions before copying any of this.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For two nights in a row, my nightly automation fired exactly on schedule and exited one second later, having done nothing. The scheduler was never the problem. The prompt I fed to the agent CLI happened to start with a line of three dashes — the YAML frontmatter of my runbook file — and the CLI parsed the entire 12-kilobyte prompt as one long command-line option, rejected it, and quit with exit code 1. Here is the failure, the one-line fix, and the check I now run on every unattended job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job fired on time and failed in one second
&lt;/h2&gt;

&lt;p&gt;I run a small unattended pipeline on my Mac. Every night at 21:00, a launchd agent executes a shell script. The script checks that a logged-in browser is reachable, then hands a runbook file to an AI coding agent in print mode, which executes the night's tasks: a few community replies, a read-only check of one account, and a blog post for this site.&lt;/p&gt;

&lt;p&gt;On the morning of the third day I opened the logs. Both nights looked like this (the two Chinese lines translated — the script logs in Chinese, everything else verbatim):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== nightly-yanghao start 2026-10-16 21:00:05 =====
[Fri Oct 16 21:00:05 CST 2026] CDP preflight OK (localhost:9223)
error: unknown option '---
created: 2026-10-15
type: runbook
...
[Fri Oct 16 21:00:05 CST 2026] claude exit rc=1
===== nightly-yanghao end 2026-10-16 21:00:06 rc=1 =====
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline table, both nights:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Night&lt;/th&gt;
&lt;th&gt;launchd fired&lt;/th&gt;
&lt;th&gt;Preflight&lt;/th&gt;
&lt;th&gt;Agent runtime&lt;/th&gt;
&lt;th&gt;Work done&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oct 15&lt;/td&gt;
&lt;td&gt;21:00:05&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 16&lt;/td&gt;
&lt;td&gt;21:00:05&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scheduler did its job perfectly. The script did its preflight correctly. The agent CLI then rejected its instructions as a malformed flag, and the whole run was over before a human heartbeat's worth of time had passed. Two nights, six tasks per night, twelve missed executions, and the system was green on every dashboard I had — because I had no dashboard, only a log file nobody read.&lt;/p&gt;

&lt;p&gt;One thing worth crediting: macOS itself kept receipts I never looked at. launchd records the last exit status of every job it manages — &lt;code&gt;launchctl list&lt;/code&gt; shows it in the Status column, where a &lt;code&gt;1&lt;/code&gt; had been sitting since the first night. And the plist format has native &lt;code&gt;StandardOutPath&lt;/code&gt; and &lt;code&gt;StandardErrorPath&lt;/code&gt; keys that persist a job's output to files without any wrapper script. My wrapper did capture stderr into its own log, so nothing was lost — but between launchd's exit-status table and my log, the failure was recorded twice and read zero times. The observability was there. The observing was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt was valid Markdown and an invalid command line
&lt;/h2&gt;

&lt;p&gt;My runbook is a Markdown file, and like most of my Markdown files it starts with YAML frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-10-15&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runbook&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My script passed the file's contents to the CLI as a positional argument, roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;runbook.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI's own help text describes print mode plainly: &lt;code&gt;use -p/--print for non-interactive output&lt;/code&gt;. What the help text does not need to say — because it is a convention every CLI follows — is that argument parsing happens before your positional argument is interpreted. A positional argument that begins with a dash is not a positional argument at all. It is an option, by definition, at the layer where the shell's words meet the program's parser.&lt;/p&gt;

&lt;p&gt;This convention has a name and a number. POSIX.1's Utility Syntax Guidelines define &lt;code&gt;--&lt;/code&gt; as the delimiter that ends option parsing (Guideline 10): everything after it is an operand, even if it begins with a dash. Every mainstream argument parser — glibc's getopt, Python's argparse, the Node and Go flag libraries — implements some flavor of this rule. That means two things. First, my failure was not a quirk of one particular CLI; any standards-following parser would have rejected the input the same way. Second, there was always an escape hatch I did not know I needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;runbook.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# parses fine — everything after -- is an operand&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the parser saw &lt;code&gt;---\ncreated: 2026-10-15\ntype: runbook\n...&lt;/code&gt;, tried to match it against its known flags, failed, and printed &lt;code&gt;error: unknown option '---...'&lt;/code&gt; with the entire runbook echoed back as the offending "option". Every layer of my pipeline was working as designed. The design was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  My preflight checked everything except the thing that runs
&lt;/h2&gt;

&lt;p&gt;This is the part that stung, because I had been careful. The script has a &lt;code&gt;--check&lt;/code&gt; mode. Before I loaded the launchd job, I ran the preflight and watched it print &lt;code&gt;PREFLIGHT-OK&lt;/code&gt;. I verified the schedule, the working directory, the browser probe, the log directory.&lt;/p&gt;

&lt;p&gt;What the preflight validated: the environment around the task. What it never validated: the task itself. The one line that actually invoked the agent was exercised for the first time at 21:00 on the first night, in production, unattended. Loading a schedule is not running a job. A green preflight means the runway is clear; it says nothing about whether the plane can fly.&lt;/p&gt;

&lt;p&gt;There is a subtler version of this trap worth naming. Even if my preflight had invoked the CLI, it would have invoked it with a fixed, known-good test string — while the real run reads its input from an external file. A preflight with hardcoded inputs proves the binary starts; it says nothing about the contract between the binary and the data you feed it at runtime. The honest fix is a smoke test with the real inputs, end to end: after enabling any unattended job, run it once by hand, exactly as the scheduler will. If that is too expensive, read the first real run's log the next morning before you assume the system exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  A notification is not monitoring
&lt;/h2&gt;

&lt;p&gt;One detail deserves honesty, and a harsher framing than I first gave it. My script did not stay silent: on a non-zero exit it fired a macOS desktop notification — something went wrong, see the log. It fired on both nights. I never saw either one, because notifications on this machine are where focus-mode banners go to die.&lt;/p&gt;

&lt;p&gt;My first draft of this section asked "why did the notification fail?" The better question is why a desktop banner was the observability layer of an unattended job in the first place. A banner is volatile, silenced by Do Not Disturb, and requires a human to be looking at the screen within seconds. Rob Ewaschuk's classic essay &lt;em&gt;My Philosophy on Alerting&lt;/em&gt; (reprinted in Google’s &lt;em&gt;Site Reliability Engineering&lt;/em&gt;) argues that passive channels like email are of very limited value precisely because they get buried; an alert that does not demand immediate human action is noise. A transient GUI banner on an unattended machine is worse than email — it pages no one, ever. That is not a notification problem. It is an architectural anti-pattern: a desktop UI component carrying a production observability responsibility.&lt;/p&gt;

&lt;p&gt;The deeper issue is the direction of the signal. Mine was push-on-failure: the system tries to announce its own death. But anything announced by the dying component shares a failure domain with that component — if the Mac loses power, hangs, or the process is killed before the trap runs, the announcement dies with it. Absolute silence, indistinguishable from success. The robust pattern inverts the direction: the job sends a success heartbeat to an external service when it completes, and the service — not the job — raises the alarm when the heartbeat stops arriving. This is the dead man's switch, or inverted heartbeat, and it is the entire business model of services like Healthchecks.io, Cronitor, and Dead Man's Snitch. Google's SRE book devotes a chapter of its cron discussion to exactly this: for periodic batch jobs, "the expected output did not appear" is the symptom worth alerting on, not any signal the job itself emits.&lt;/p&gt;

&lt;p&gt;Every mature scheduler has some version of this lesson built in. Classic cron's &lt;code&gt;MAILTO&lt;/code&gt; captures whatever the job printed and mails it — which is why &lt;code&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt; in a crontab silently disarms it. systemd has &lt;code&gt;OnFailure=&lt;/code&gt;, which activates a separate diagnostic unit when a service fails, so the reporting path is a different process from the failing one. macOS gave me the exit-status table I never read. My cheap version, until I build a real external heartbeat, is a rule instead of a tool: the first morning after any new unattended job goes live, reading its log is a scheduled task, not a vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one pipe, with three caveats
&lt;/h2&gt;

&lt;p&gt;The corrected invocation strips the frontmatter and feeds the prompt through standard input, where no option parsing applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^---$/{c++; next} c&amp;gt;=2'&lt;/span&gt; runbook.md | claude &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;stdin beats the positional argument here for two reasons, and only one of them is option parsing. The other is size: positional arguments live in the fixed-size buffer &lt;code&gt;execve&lt;/code&gt; copies into the new process, capped by &lt;code&gt;ARG_MAX&lt;/code&gt; (1 MB on macOS). A prompt that grows past that ceiling fails with &lt;code&gt;E2BIG&lt;/code&gt; before the program starts at all. A pipe is a stream with no such ceiling, so the fix removes the parsing ambiguity and the size limit in one move.&lt;/p&gt;

&lt;p&gt;Three caveats make this production-grade rather than a snippet:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline exit codes lie by default.&lt;/strong&gt; A pipeline's exit status is its last command's. If awk crashed mid-stream and the CLI treated empty input as success, the failure would vanish. &lt;code&gt;set -o pipefail&lt;/code&gt; makes the pipeline report any stage's failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;launchd does not load your shell's locale.&lt;/strong&gt; A job started by launchd runs without the locale environment your terminal has; byte-oriented tools like awk can misparse multi-byte UTF-8 content unless you set &lt;code&gt;LC_CTYPE&lt;/code&gt; explicitly in the script. My runbooks contain Chinese — this one is not theoretical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;---&lt;/code&gt; is also Markdown syntax.&lt;/strong&gt; In CommonMark, three dashes are a thematic break — a horizontal rule. My one-liner counts &lt;em&gt;every&lt;/em&gt; line of three dashes, so a horizontal rule in the runbook body would be silently deleted from the prompt: a parsing crash traded for silent data corruption. The hardened filter only strips a frontmatter block at line 1 and stops at its first closing delimiter:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR==1 &amp;amp;&amp;amp; /^---$/ {f=1; next} f &amp;amp;&amp;amp; /^---$/ {f=0; next} !f'&lt;/span&gt; runbook.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested the pipe with a two-word prompt before trusting it, which is what I should have done on day one. The next scheduled run executed all six tasks end to end.&lt;/p&gt;

&lt;p&gt;The total cost of the bug was two quiet nights. The total cost of not noticing could have been weeks — nothing in the system would ever have gotten louder on its own. That asymmetry is the real lesson, and it is why the incident earned a permanent entry in the family's incident log rather than a quick fix and a shrug: unattended automation fails silently by default, and silence only breaks where you have built a place for it to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed after the incident
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strip metadata before handing files to a CLI.&lt;/strong&gt; Any file that might carry frontmatter gets filtered before its content becomes an argument; stdin beats positional arguments for anything that can start with a dash; and where an argument is unavoidable, &lt;code&gt;--&lt;/code&gt; ends option parsing before it begins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoke-test the invocation with real inputs, not just the environment.&lt;/strong&gt; A preflight proves the surroundings are ready; only a real run with real data proves the pipeline runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the first-run log the next morning.&lt;/strong&gt; New unattended jobs get a scheduled human review of their first execution, before trust is granted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat silence as the alarm.&lt;/strong&gt; Until a real external heartbeat exists, the interim rule is explicit: no news is bad news, and something — eventually an external service, for now a calendar entry — is responsible for noticing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the incident down where future runs will read it.&lt;/strong&gt; My nightly agent now greps the incident log before starting and appends new entries before finishing, so a mistake is expensive exactly once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is clever. That is the point. Unattended systems do not need cleverness; they need the dull discipline of checking that the thing you built is the thing that ran.&lt;/p&gt;







&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/silent-failure-unattended-automation/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/silent-failure-unattended-automation/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>cli</category>
    </item>
    <item>
      <title>Keeping New Sites Visible to AI Search Crawlers</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:35:34 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/keeping-new-sites-visible-to-ai-search-crawlers-57p5</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/keeping-new-sites-visible-to-ai-search-crawlers-57p5</guid>
      <description>&lt;p&gt;&lt;em&gt;AI crawler policies, bot lists, and platform defaults change quickly. This is a snapshot from October 2026, based on what I run on my own sites today. Re-check the official lists before you copy any of it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Since July 2025, Cloudflare blocks AI crawlers on newly onboarded domains by default. If you do nothing, a new site on Cloudflare is simply absent from ChatGPT, Claude, and Perplexity — and nothing errors, nothing warns you, and your analytics stay silent because the traffic never arrives. Here is the four-step checklist I now run on every new static site before I consider it launched.&lt;/p&gt;

&lt;h2&gt;
  
  
  New Cloudflare domains hide from AI crawlers by default
&lt;/h2&gt;

&lt;p&gt;This is the trap, and it is silent. Cloudflare turned on default AI-crawler blocking for new domains on 2025-07-01 (Cloudflare, 2025). An existing site keeps its settings; a &lt;em&gt;new&lt;/em&gt; domain inherits the block. Your pages render fine, Google Search Console shows nothing unusual, and the first symptom is that AI assistants simply never mention your site — a symptom with no dashboard.&lt;/p&gt;

&lt;p&gt;The crawl access layer is worth treating as launch infrastructure, same as DNS and SSL. I missed this on exactly zero sites only because the family SOP caught it before launch — the checklist below is that SOP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Explicitly allow search-class crawlers in Cloudflare AI Crawl Control
&lt;/h2&gt;

&lt;p&gt;Cloudflare's AI Crawl Control is the switch that matters most. Go in and explicitly allow the &lt;strong&gt;search-class&lt;/strong&gt; crawlers — the bots that fetch pages to answer user queries: &lt;code&gt;OAI-SearchBot&lt;/code&gt; (ChatGPT search), &lt;code&gt;ClaudeBot&lt;/code&gt; (Claude), and &lt;code&gt;PerplexityBot&lt;/code&gt; (Perplexity). These are the ones that make your content quotable inside AI answers.&lt;/p&gt;

&lt;p&gt;Also allow the &lt;strong&gt;user-triggered fetchers&lt;/strong&gt; — bots that fetch a page because a user explicitly asked an AI to read it. Google's list (updated 2025-10/11) includes &lt;code&gt;Google-NotebookLM&lt;/code&gt;, &lt;code&gt;Google-CWS&lt;/code&gt; (Chrome's AI features), and &lt;code&gt;Google-Pinpoint&lt;/code&gt;. Being read on demand is the same value as being read by search bots; block it and "summarize this page for me" fails for your domain.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crawler class&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Why it fetches&lt;/th&gt;
&lt;th&gt;My setting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Search-class AI bots&lt;/td&gt;
&lt;td&gt;OAI-SearchBot, ClaudeBot, PerplexityBot&lt;/td&gt;
&lt;td&gt;Answer user queries with your content&lt;/td&gt;
&lt;td&gt;Allow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User-triggered fetchers&lt;/td&gt;
&lt;td&gt;Google-NotebookLM, Google-CWS, Google-Pinpoint&lt;/td&gt;
&lt;td&gt;A user asked an AI to read this page&lt;/td&gt;
&lt;td&gt;Allow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training-class crawlers&lt;/td&gt;
&lt;td&gt;GPTBot, Google-Extended&lt;/td&gt;
&lt;td&gt;Model training corpora&lt;/td&gt;
&lt;td&gt;Allow (optional — I allow them)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 2: Name the bots in robots.txt — and skip invented fields
&lt;/h2&gt;

&lt;p&gt;robots.txt still works, but only for documented fields. Google has said since 2024-10 that it ignores undocumented self-invented directives like &lt;code&gt;NoAI&lt;/code&gt; (Google, 2024). Writing &lt;code&gt;NoAI: false&lt;/code&gt; or similar into robots.txt controls nothing anywhere. Control goes through the official mechanisms: Cloudflare AI Crawl Control for the edge, and named user-agent groups in robots.txt for everything else.&lt;/p&gt;

&lt;p&gt;On my own sites the robots file names the AI crawlers explicitly — ten user-agent groups, every one of them allowed — and a contract test fails the build if any group gains a &lt;code&gt;Disallow&lt;/code&gt;. Named groups beat wildcards here: you can see exactly who you admitted, and a diff on the file is a readable audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Keep llms.txt, but do not count it as a Google lever
&lt;/h2&gt;

&lt;p&gt;Google's official guidance across three documents (2025-05 generative AI content guidance, 2025-12 AI features documentation, and the 2026-05 AI optimization guide) settles two questions: there is &lt;strong&gt;no separate AI index&lt;/strong&gt; to optimize for, and llms.txt is &lt;strong&gt;not counted&lt;/strong&gt; by Google Search. GEO, in Google's own framing, is traditional SEO done well — crawlable pages with citable, quotable content.&lt;/p&gt;

&lt;p&gt;I still ship &lt;code&gt;/llms.txt&lt;/code&gt; on every site, with this blog's &lt;a href="https://javaeye.dev/llms.txt" rel="noopener noreferrer"&gt;own copy&lt;/a&gt; covering the hub and main pages. It costs almost nothing and non-Google consumers use it. But I keep it out of the ROI story: for Google, its expected value is zero, and pretending otherwise would distort what I measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Reconcile with the GSC Generative AI report every month
&lt;/h2&gt;

&lt;p&gt;Since 2026-06, Search Console has a Generative AI report showing how often your content appears in AI experiences (Google, 2026). Two things to know before you read it: it reports &lt;strong&gt;impressions, not clicks&lt;/strong&gt;, and it has no query dimension. You can see that AI surfaces showed your page, not which question triggered it.&lt;/p&gt;

&lt;p&gt;This report is now part of my monthly inspection routine, next to classic queries and pages. One related setting worth leaving alone: the "Search generative AI" participation control (opened globally 2026-08-31) is not a ranking lever in either direction — opting out does not protect rankings and opting in does not boost them. I keep participation on, because for a content site AI visibility is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verified on my own sites, and what I did not
&lt;/h2&gt;

&lt;p&gt;On this blog the full stack went live on 2026-09-29: robots.txt with ten named AI-crawler groups all allowed, &lt;code&gt;/llms.txt&lt;/code&gt; covering the hub and main pages, a Cloudflare zone audit showing no AI blocking, and curl checks confirming every rule served what I intended. Contract tests lock the robots file so a regression fails the build instead of slipping out. The same SOP now runs across my family of static tool sites — &lt;a href="https://wordroost.com" rel="noopener noreferrer"&gt;the newest of them&lt;/a&gt; went through the same checklist on launch day.&lt;/p&gt;

&lt;p&gt;The honest boundary: all of this verifies &lt;strong&gt;crawl access&lt;/strong&gt;, not outcomes. I can prove the bots are admitted; I cannot yet point at traffic and say the checklist caused it. That part needs months of the GSC Generative AI report before anyone — including me — should claim it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;Allow search-class and user-triggered AI crawlers in Cloudflare AI Crawl Control, name them in robots.txt with no invented fields, keep llms.txt as a free extra with zero Google ROI, and reconcile against the GSC Generative AI report monthly.&lt;/p&gt;

&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/ai-crawler-governance-checklist/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/ai-crawler-governance-checklist/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Google's Request Indexing Quota: What I Measured</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Sun, 18 Oct 2026 13:42:03 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/googles-request-indexing-quota-what-i-measured-6ce</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/googles-request-indexing-quota-what-i-measured-6ce</guid>
      <description>&lt;p&gt;&lt;em&gt;Search Console limits, labels, and quotas change without notice — the quota here is not documented at all. Every number below is a snapshot from early October 2026, measured on my own Google account. Re-measure on yours before you plan around it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google Search Console's "Request Indexing" has a daily quota. The help documentation confirms that a limit exists and never says how large it is; the number people repeat in forums — ten URLs a day — has no official source I could find. I spent three days measuring mine. The short version: the quota is shared by every property under one Google account rather than counted per site, the daily number floats, and it recovers hours before Pacific midnight. Here is what I actually logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quota belongs to the account, not to the site
&lt;/h2&gt;

&lt;p&gt;This is the finding that changed how I work, and it is the one I got wrong first. My earliest notes said the limit was counted per account, per property, per day — I assumed each site had its own budget. Cross-property testing disproved it. A small family of free tool sites is what I actually run — the eight of them are listed on my &lt;a href="https://dev.to/tools"&gt;/tools&lt;/a&gt; page, and &lt;a href="https://wordroost.com" rel="noopener noreferrer"&gt;WordRoost&lt;/a&gt; is the newest — plus this blog, and when one site under my account had spent the day's quota, a different site under the same account could not submit either.&lt;/p&gt;

&lt;p&gt;The clearest single day is 2026-10-09. Sixteen requests succeeded that day, and they were spread across four different properties under one account: one site took one, another took six, this blog took eight, and a fourth took one. Property boundaries reset nothing. The pool was the account's.&lt;/p&gt;

&lt;p&gt;Two things that do &lt;em&gt;not&lt;/em&gt; change the pool, which I tested specifically because both are common advice: the IP address you submit from, and whether you use an incognito window. Neither made any difference. The only action that gets you a different pool is a different Google account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily number floats, so plan for a range
&lt;/h2&gt;

&lt;p&gt;I have two clean samples of a full day's capacity: 11 and 13. On a third day, 16 requests went through and the 17th was rejected. So "the quota" is not a fixed number you can memorise. Treat it as a range somewhere in the low teens, and stop guessing at an exact figure.&lt;/p&gt;

&lt;p&gt;The shape of a batch tells you which end of that range you are on. On 2026-10-07 my blog's batch submitted four URLs and the fifth was rejected — the account had already been used that day, so four was what remained rather than the day's total. The next full batch, on 2026-10-09, submitted eight URLs with nothing left over. Same account, same tool, very different amounts available, because the pool depends on whatever else the account did that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery happens before Pacific midnight
&lt;/h2&gt;

&lt;p&gt;The advice I kept reading was to wait for midnight in Pacific time. My logs disagree. After one morning cap, the next day's first submissions at 13:19 Beijing time went straight through. That is hours earlier than Pacific midnight, so I moved my working window to "any time after 13:00 Beijing" and stopped waiting until the evening.&lt;/p&gt;

&lt;p&gt;I also tested the other popular claim, that the quota resets at 15:00 Beijing. It does not. On one day the first request at 14:31 was already rejected, a re-test at 15:00:54 was still rejected, and the pool only came back at 17:05. Recovery is not always generous either: one recovery gave exactly one submission before the cap returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first click is a usable signal for how much is left
&lt;/h2&gt;

&lt;p&gt;This is the small observation I rely on most now. When the quota is nearly gone, the first click on a URL returns a transient error — a generic message that there was a problem submitting the indexing request — and the &lt;em&gt;second&lt;/em&gt; click succeeds. When the pool is fresh, the first click succeeds and there is no error at all.&lt;/p&gt;

&lt;p&gt;Both batches fit the pattern exactly. The 2026-10-07 batch, running against a partly used pool, had a first-click error on all four URLs. The 2026-10-09 batch, running against a fresh pool, had zero errors across eight.&lt;/p&gt;

&lt;p&gt;So if you are working through a batch and every first attempt fails, that is not a broken tool. It is the quota telling you it is nearly empty — and it is a good moment to write down where you stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now: stop at the cap, roll the rest, keep the screenshots
&lt;/h2&gt;

&lt;p&gt;Three rules, all of them forced by these runs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop the moment the quota is hit.&lt;/strong&gt; The remaining URLs go to the next day. Switching accounts to keep going is not worth it, for the reason in the next section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot every confirmation.&lt;/strong&gt; The success state is the line saying the URL has been added to the priority crawl queue. A line you typed into your own log is not evidence; a screenshot is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check read-only.&lt;/strong&gt; After a batch, checking whether a URL is already indexed does not consume quota. Only the submit action does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For cost: a four-URL batch ran about five minutes end to end, and an eight-URL batch about eight minutes. The clicking is not the bottleneck. The queue is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second account is not a second full quota
&lt;/h2&gt;

&lt;p&gt;The obvious workaround is to keep a spare Google account and switch when the first one runs dry. I tried it, and it mostly does not work. Quota is attached to the account, and my spare accounts already carried properties of their own — so their budget for the day was partly spent before I arrived. On the worst day, a spare account's very first request was rejected.&lt;/p&gt;

&lt;p&gt;Keep a spare if you want one, but look at what else that account does before you count on it. An account with several properties under it is not a fresh account.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell anyone batching indexing requests
&lt;/h2&gt;

&lt;p&gt;The quota is real, it is smaller than the folklore suggests, and it is not the thing worth optimising. What matters is knowing which pool you are drawing from and stopping cleanly when it is empty. Everything above is one account's behaviour in one week of October 2026, not a documented Google promise — so measure your own before you build a schedule on it.&lt;/p&gt;

&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/google-request-indexing-quota/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/google-request-indexing-quota/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Niche Research: My Go/No-Go Validation Workflow</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Fri, 16 Oct 2026 23:50:58 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/niche-research-my-gono-go-validation-workflow-4l82</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/niche-research-my-gono-go-validation-workflow-4l82</guid>
      <description>&lt;p&gt;I run a small family of free, no-login tool sites — eight of them are listed on my &lt;a href="https://dev.to/tools"&gt;/tools&lt;/a&gt; page — and every one of them lives or dies on search traffic. Somewhere around the fifth site I figured out the expensive truth: the real cost of a new site isn't the build, it's committing to the idea before the build. Register a domain on a guess and you're quietly betting three months of work on a hunch. This post is my full validation workflow — every gate a candidate idea has to pass between "interesting word" and "domain registered" — plus the trap that nearly sank one of my sites anyway.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Keyword tools and search behavior change fast. Every number here is a snapshot from early October 2026 — treat this as a workflow demo, not a forever-true dataset.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate one: combine word roots into a candidate pool before you have an "idea"
&lt;/h2&gt;

&lt;p&gt;The starting point is never a flash of inspiration. It's a collision of words. My fixed routine: put together a pool of roots — tool words, scenario words, audience words, roughly fifty of them — and combine them into a few hundred candidate phrases. This stage is deliberately unfiltered. The only goal is to build a pool large enough that the next stage, the data, has something real to vote on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate two: the data checks three things — competition, volume, and whether anyone pays
&lt;/h2&gt;

&lt;p&gt;Every candidate phrase goes into a keyword database (I use Semrush). I look at exactly three numbers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyword difficulty low enough.&lt;/strong&gt; A new domain has no authority. A word with high KD isn't an opportunity, it's a headstone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volume large enough.&lt;/strong&gt; Too small and it can't carry a standalone site — at best it earns one page on a site I already run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPC above zero.&lt;/strong&gt; This one gets ignored a lot, and it's the most honest signal in the set. Cost-per-click is direct evidence that somebody pays for that traffic. Advertisers spending money means real demand with a real business behind it; a candidate where every CPC reads zero is usually people searching for fun.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A candidate that fails any of the three is deleted. No appeal.&lt;/p&gt;

&lt;p&gt;For a live example, the phrase-match probe I ran for this very topic this morning returned eight keywords in the "niche research" family: 110 to 720 monthly searches each, difficulties between 18 and 33, mostly zero CPC — a topic people clearly read about, but one nobody advertises on. Fine for a blog post. Useless as a business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate three: zero competitors is a graveyard, not an opening
&lt;/h2&gt;

&lt;p&gt;Candidates that clear the data gate get a manual look at the actual search results. This judgment is counterintuitive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No competitors at all? Be afraid, not happy.&lt;/strong&gt; The likely story isn't that nobody thought of it — it's that everyone who did is dead. Demand that weak doesn't support a business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One or two competitors whose pages haven't been touched in years? That's the green light.&lt;/strong&gt; Demand is proven — someone is alive serving it — and the reigning champion has stopped trying. A new site with a better page can pry the position loose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An old, unmaintained number-one ranking is my favorite signal in this whole workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate four: communities hold pain that has no search volume yet
&lt;/h2&gt;

&lt;p&gt;Keyword tools can only see demand that already shows up as searches. So I also hang out where the target audience complains — Reddit boards, niche forums — and read how people describe their problems and advise each other. Plenty of real pain is too small or too new to appear in any tool, but you can watch it happen in threads. Those complaints are free research, and they double as a side check that the audience I'm targeting is genuinely annoyed by the thing I want to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate five: the domain is the last gate, never the first
&lt;/h2&gt;

&lt;p&gt;Only after a candidate passes word pool, data, competitor shape, and community pain do I register a domain. The order is the whole point. Once a domain exists, sunk cost kicks in and I start rationalizing. Keeping registration last keeps abandoning a candidate free — any failed gate means delete the row and move on, at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that got me anyway: change the keywords, and the competitors vanish
&lt;/h2&gt;

&lt;p&gt;This workflow wasn't born complete. When I built my seating-chart tool, &lt;a href="https://sortaseat.com" rel="noopener noreferrer"&gt;SortaSeat&lt;/a&gt;, the competitor check on the words we'd chosen came back clean, and we green-lit the project. Later we found out the space already had several popular, heavyweight solutions. They simply ranked under a &lt;em&gt;different set of keywords&lt;/em&gt; — the words we'd validated with weren't the words normal people searched, so the incumbents were invisible in our results.&lt;/p&gt;

&lt;p&gt;The lesson is now written into the workflow in bold: never run the competitor check with only your own chosen words. Search again from different audience perspectives with different phrasing, and read who actually sits on the first page, how old their sites are, and whether anything still gets updated. Search-word bias is the easiest way to fool yourself in this entire process — I know, because it fooled me.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more habit: every site gets its own blog
&lt;/h2&gt;

&lt;p&gt;The newest addition to the routine: for every tool site, I spin up a separate blog dedicated to that brand's topic. The tool site catches search traffic; the blog tells the brand's story and advertises the tool in long form — each feeding the other. How that plays out in practice is a post of its own, coming next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell a past me
&lt;/h2&gt;

&lt;p&gt;None of these gates is glamorous, and every one of them is two orders of magnitude cheaper than writing code. Word pool, three data checks, competitor shape, community pain, domain last — any single gate can kill a candidate, and a killed candidate is not a failure, it's three months saved. The tool numbers are snapshots; the workflow is the asset.&lt;/p&gt;

&lt;p&gt;I wrote about the demand side of this system in my post on &lt;a href="https://dev.to/blog/keyword-matrix-gap-analysis/"&gt;finding keyword gaps across my existing sites&lt;/a&gt; — the matrix finds where my current family can grow. This workflow decides which new directions get to exist at all.&lt;/p&gt;

&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/niche-research-validation-workflow/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/niche-research-validation-workflow/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>marketing</category>
    </item>
    <item>
      <title>The 662-Page Indexing Report: Two New States</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Wed, 14 Oct 2026 01:42:41 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/the-662-page-indexing-report-two-new-states-2pho</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/the-662-page-indexing-report-two-new-states-2pho</guid>
      <description>&lt;p&gt;&lt;em&gt;Search Console labels and behavior change over time. Every figure here is a snapshot from October 4–5, 2026 — verify against current Google documentation before acting on it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Last week I published the week-one post on a 662-page static site: one indexed homepage, everything else "URL is unknown to Google", and a promise to run the full-batch inspection in week two. I ran it a day early, on day 7. Here are the final week-one numbers: all 662 URLs inspected, zero state flips on the big site — and the indexing states I was waiting for showed up the same week, on the family's smallest site instead. Plus the first search data worth reading: 92 impressions and a position-21 head term.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full batch: 662 inspected, and the queue did not move
&lt;/h2&gt;

&lt;p&gt;The full inspection I deferred from launch day happened on day 7 (October 4, 2026), once the results could rank pages against each other. Here is the complete distribution:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Submitted and indexed&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovered – currently not indexed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawled – currently not indexed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;661&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one indexed page is still the homepage, last crawled October 1, and its referring URLs are still a link from my blog's tools page plus the site itself. Meanwhile the sitemap grew from 568 to 662 URLs during the week and processed with zero errors. Six days after my first post, the queue looks exactly the same at full resolution as it did in a 12-page sample: this is what a healthy week one on a new programmatic site looks like, and the honest headline is that there is no headline yet.&lt;/p&gt;

&lt;p&gt;One practical note for anyone running the same play: 662 individual URL Inspection calls took about 80 minutes at a polite per-request delay, and roughly one in four requests failed with a transient SSL error and needed retrying. Batch with resume support, or you will re-spend that quota every time something interrupts the run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The states showed up — on the 48-page site, not the 662-page one
&lt;/h2&gt;

&lt;p&gt;The week's most informative data came from the smallest site in the family, a 48-page work-hours calculator site called TallyPunch that is three weeks older than the word-list site. A seven-URL scan there captured the family's first two non-terminal indexing states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;State (Oct 4)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/about/&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/biweekly-time-card/&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/what-is-time-and-a-half/&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/time-card-calculator/&lt;/td&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/how-many-hours-is-12pm-to-8pm/&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Discovered – currently not indexed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/how-many-hours-is-9am-to-5pm/&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Crawled – currently not indexed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same family, same infrastructure, same sitemap-and-internal-links launch pattern — opposite trajectories. My working hypothesis, and it is only a hypothesis: a 662-page sitemap lands in Google's queue as one big batch that gets paced, while a 48-page site gets crawled to completion in days. Small sites get depth first; big small sites wait in line longer. If the week-two rerun on the big site shows the first flips, I will take that as support.&lt;/p&gt;

&lt;p&gt;The checklist-driven responses are deliberately boring. The Discovered page gets an internal-link priority check (it currently has no referring URLs) and then patience. The Crawled page — Google fetched it on September 28 and declined to index it — gets two weeks of observation with zero changes, until October 18, before any attribution. Changing a page during its observation window destroys the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first query data worth reading
&lt;/h2&gt;

&lt;p&gt;Indexing states tell you what Google did. Query data tells you what searchers did. Until this week, every site in the family sat under my 20-impression floor: below that, I write "insufficient sample" and touch nothing. This week the smallest site crossed it.&lt;/p&gt;

&lt;p&gt;The 48-page site recorded 92 impressions across 50 query rows in the measurement window (September 22 to October 1; the API's rolling window lags by a few days). Zero clicks, so click-through rate is still meaningless. What matters is position:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Head term:&lt;/strong&gt; "30 minute lunch break calculator" — 4 impressions at &lt;strong&gt;position 21&lt;/strong&gt;. A calculator page is one page away from page one of Google for its exact keyword, three weeks after launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same cluster, hyphenated variant:&lt;/strong&gt; "30-minute lunch break calculator" — 4 impressions at position 38.8, a reminder that near-duplicate queries rank independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mid-tail:&lt;/strong&gt; time-card and hours-to-decimal terms each show 1–2 impressions at positions 65–86 — normal new-site depth, nothing to act on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One page at position 21 is not a trend. It is the family's first discriminating signal: proof that a brand-new domain with zero backlinks can get within sight of page one on a specific, answer-shaped query. The next checkpoint is whether it enters the top 10, and that is a week-two question, not a week-one one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed this week: nothing
&lt;/h2&gt;

&lt;p&gt;The checklist I wrote on launch day said "unknown = normal, check again in two weeks", and the full-batch data says the same thing with 662 rows instead of 12. The complete action list from this inspection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put the Crawled-state page on a watchlist with a hard date — no edits before October 18.&lt;/li&gt;
&lt;li&gt;Recheck the Discovered-state page at the next inspection; add an internal link entrance only if it stalls.&lt;/li&gt;
&lt;li&gt;Rerun the 662-page batch next week, same caliber, and compare flips.&lt;/li&gt;
&lt;li&gt;Keep interpreting nothing under 20 impressions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Boring is the point. New-site indexing is a queue, and the data so far rewards the people who wait in it without fidgeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  A meta footnote
&lt;/h2&gt;

&lt;p&gt;The week-one post I promised this follow-up from is, as I write this on October 5, itself "Discovered – currently not indexed" — 38 hours after publication. The post about that state now lives in that state. Both are queue positions, not verdicts; that was the thesis, and apparently it applies to the thesis too.&lt;/p&gt;




&lt;p&gt;This article was originally published on my blog: &lt;a href="https://javaeye.dev/blog/week-one-full-indexing-report/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/week-one-full-indexing-report/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Programmatic SEO Examples: 8 Sites, 946 Pages</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Tue, 13 Oct 2026 01:32:35 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/programmatic-seo-examples-8-sites-946-pages-4hk1</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/programmatic-seo-examples-8-sites-946-pages-4hk1</guid>
      <description>&lt;p&gt;&lt;em&gt;Programmatic SEO advice and search volumes change fast. Page counts and keyword figures here are snapshots from October 3–4, 2026 — verify against current data before acting on them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Search "programmatic SEO examples" and you get the same case studies every time: Zapier, Canva, Wise — companies with engineering teams and data budgets. Here is the other kind of example. I am one developer running eight small static tool sites: 946 pages total at last count (October 4, 2026), every page generated from a structured data source, no database, no CMS, no build pipeline beyond a script. This post is the full inventory — what each site generates pages from, the one matrix type the big-brand lists never mention, and the build-time gate that keeps thin pages from shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What programmatic SEO actually means at this scale
&lt;/h2&gt;

&lt;p&gt;Programmatic SEO = pages generated from structured data instead of written one at a time. At Zapier scale that means thousands of integration pages pulled from a live database. At my scale it means: one template, one data file, one script, and a folder of HTML files uploaded to a CDN. The sites are free tools, no login, no ads worth mentioning. Every page exists because a keyword said people want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eight sites, in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;What generates them&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WordRoost&lt;/td&gt;
&lt;td&gt;662&lt;/td&gt;
&lt;td&gt;Word-list matrix: words containing / starting / ending in letter combinations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SparkFont&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;Font style generators + printable alphabet pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TallyPunch&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;Work-hours calculators: time cards, overtime, shift math&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bracketree&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;Tournament bracket templates by field size and format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GridRoster&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;Rotating shift schedule grids&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StudCalc&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;Construction material calculators: concrete, gravel, lumber&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueCubic&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;DIY material calculators for home projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SortaSeat&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Seating chart templates by layout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;946 pages across eight domains. Notice what is not in the table: a database. Each site's "programmatic" layer is a JSON or CSV file checked into the repo, and the entire build runs in seconds on a laptop. That is a deliberate constraint, not a budget limit — the same constraint that lets me keep every page static and every tool no-login.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix type big-brand case studies never show you
&lt;/h2&gt;

&lt;p&gt;Every famous pSEO example is a &lt;em&gt;lookup&lt;/em&gt; play: integrations, job postings, city pages — one entity, one page. My largest site runs a different shape I rarely see documented: a &lt;strong&gt;combinatorial constraint matrix&lt;/strong&gt;. WordRoost's pages are not "words that start with Q". They are intersections — five-letter words containing both Q and X, words ending in ELN — generated by crossing word-length rows with letter-combination columns. The October 3 sitemap shows 662 such intersections, each backed by a real filtered word list.&lt;/p&gt;

&lt;p&gt;The matrix has a property lookup pages do not: every cell is cheap to validate. A word list either contains matches or it does not. That enables the next section's gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-time gate that keeps thin pages from ever shipping
&lt;/h2&gt;

&lt;p&gt;The dirty secret of pSEO at any scale is empty pages. A generator that blindly produces one page per matrix cell will happily ship "five-letter words ending in IUE" — a page with zero real words on it. Google has seen a billion of these and indexes none of them.&lt;/p&gt;

&lt;p&gt;My build refuses to do this, twice over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero-hit cells are deleted, not published.&lt;/strong&gt; The latest WordRoost build killed 12 shape-valid keywords whose word lists came back empty. They never reach the sitemap; they never existed as far as Google is concerned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong-intent keywords are rejected before the matrix even runs.&lt;/strong&gt; 155 collected keywords are sitting in a reject file with reasons attached: 55 were not list-shaped at all, 52 demanded word lengths the data does not cover, 26 needed a human-curated prefix list, 24 belonged to constraint families the generator cannot express, and a handful were tool-intent words (solver, unscrambler) that a previous CPC check had already falsified.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the part of pSEO nobody puts in a case study, and the part that decides whether a small site survives: the generator's most important output is the list of pages it refused to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy from the big names — and what I would skip
&lt;/h2&gt;

&lt;p&gt;Copy the &lt;em&gt;data-before-pages&lt;/em&gt; discipline. Zapier's integration pages work because each one answers a real query with real data. My keyword files go through the same filter: volume and difficulty thresholds (Semrush, October 2026 probe: the "programmatic SEO" cluster around this topic — examples, tools, strategy — totals roughly 530 US searches a month, with KD scores from 10 to 27) before any page earns a slot.&lt;/p&gt;

&lt;p&gt;Skip the &lt;em&gt;scale-as-goal&lt;/em&gt; mindset. The big cases measure pages in the hundreds of thousands; at that scale, thin pages are a rounding error and the domain's authority carries them. On a new domain with no backlinks, every page competes on its own merits. 946 honest pages beat 9,460 thin ones, because each honest page is a page a human would not bounce from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers look like one week in
&lt;/h2&gt;

&lt;p&gt;Honesty section, because the KPI section of a pSEO case study is where most of them lie. The eight-site family is young: the oldest pages have been crawlable for about two weeks, the newest site (WordRoost) launched September 28, 2026. Current state, measured through Google's URL Inspection API on October 3: of WordRoost's first 662 pages, the homepage is indexed and the rest are still "URL is unknown to Google" — a queue, not a verdict. On TallyPunch, three of seven sampled pages are indexed. Total search impressions across the family: under the 20-impression floor where I treat the data as insufficient sample and change nothing.&lt;/p&gt;

&lt;p&gt;So this is an example collection, not a success story — yet. The infrastructure claim I can already stand behind: every published page answers a real query with real data, and the generator proved it before shipping. The traffic claim I will report in a follow-up post when the numbers deserve one.&lt;/p&gt;




&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/programmatic-seo-examples-eight-sites/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/programmatic-seo-examples-eight-sites/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Discovered – Currently Not Indexed: Week One Data</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:54:48 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/discovered-currently-not-indexed-week-one-data-4oib</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/discovered-currently-not-indexed-week-one-data-4oib</guid>
      <description>&lt;p&gt;&lt;em&gt;Search Console labels and behavior change over time. Every figure here is a snapshot from late September / early October 2026 — verify against current Google documentation before acting on it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Roughly 1,300 people a month search some form of "discovered currently not indexed" (Semrush, October 2026 snapshot), and almost every result they land on is a generic explainer. This post is the other kind: real numbers. I run nine small static tool sites — eight of them are listed on my tools page — and the newest one, a 662-page word-list site called WordRoost, went live on September 28, 2026. Six days in, here is what the indexing states actually look like: the homepage got indexed before the sitemap was even submitted, every inner page still reads "URL is unknown to Google", and the dreaded "Discovered – currently not indexed" has not appeared at all. The checks that told me anything useful were exactly three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three states are three different problems
&lt;/h2&gt;

&lt;p&gt;Google's own Search Console help page says it plainly. "Discovered – currently not indexed: The page was found by Google, but not crawled yet. Typically, Google wanted to crawl the URL but this was expected to overload the site; therefore Google rescheduled the crawl." And right above it: "Crawled – currently not indexed: The page was crawled by Google but not indexed. It may or may not be indexed in the future; no need to resubmit this URL for crawling." (support.google.com/webmasters/answer/7440203)&lt;/p&gt;

&lt;p&gt;Read those twice, because they are not the same problem. &lt;strong&gt;Discovered-but-not-indexed&lt;/strong&gt; is a scheduling story — Google knows the page exists and simply has not fetched it yet. &lt;strong&gt;Crawled-but-not-indexed&lt;/strong&gt; is a judgment story — Google fetched the page and declined to keep it. &lt;strong&gt;"URL is unknown to Google"&lt;/strong&gt; is a third thing again: Google has not even seen the URL. Treating these as one blob called "indexing issues" sends you fixing the wrong thing with total confidence.&lt;/p&gt;

&lt;p&gt;My data agrees with the official reading. On the new site, the state I actually live in is "unknown to Google" — a queue problem, not a quality problem. The day a page flips to "Discovered – currently not indexed", my first check will not be content. It will be internal links, because a page Google discovers but keeps postponing is usually a page Google considers low-priority — and priority is what links signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week one, real numbers from a 662-page static site
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Day 0 (Sep 28)&lt;/th&gt;
&lt;th&gt;Day 6 (Oct 3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sitemap status&lt;/td&gt;
&lt;td&gt;Submitted 11:43 UTC, pending&lt;/td&gt;
&lt;td&gt;Processed, 0 errors, 0 warnings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample URL inspection (12 → 9 URLs)&lt;/td&gt;
&lt;td&gt;1 indexed, 11 unknown&lt;/td&gt;
&lt;td&gt;1 indexed, 8 unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexed page&lt;/td&gt;
&lt;td&gt;Homepage, found before sitemap submission&lt;/td&gt;
&lt;td&gt;Homepage, last crawled Oct 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details carry most of the information. First, the homepage was indexed before the sitemap went in — its referring URLs in Search Console include a link from my own blog's tools page, so plain discovery through links worked. Second, on day 6 the inner pages are still "unknown to Google" even though the sitemap has been processed with zero errors. That combination — sitemap read, homepage in, everything else unknown — is what a healthy week one looks like for a small new site. It is a queue, and the queue is long.&lt;/p&gt;

&lt;p&gt;A sibling site three weeks further along shows the transition. Of seven pages I sampled there, three are indexed (an about page and two calculator pages, first crawled September 17) and one matrix page is still unknown. The states move unevenly across page types, and they move slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only three checks have discriminating power in week one
&lt;/h2&gt;

&lt;p&gt;I wrote the inspection checklist before any data existed, on launch day, precisely so I would not improvise criteria after seeing numbers. In week one, three checks earn their place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap processed with zero errors.&lt;/strong&gt; This proves the submission channel works. If it errors here, nothing else matters yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The homepage is indexed.&lt;/strong&gt; This proves discovery and crawling work — and if it got in via a plain text link before the sitemap, internal linking is doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL Inspection returns states, not errors.&lt;/strong&gt; This proves the API channel works and gives you the state distribution to watch over time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else — impressions, average position, click-through rate — is zero or noise in week one. My standing rule: under 20 impressions in a window, I write down "insufficient sample" and touch nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is fixing things when there is nothing to fix
&lt;/h2&gt;

&lt;p&gt;The most dangerous moment in week one is seeing zero data and concluding something must be done. Chasing "indexing fixes" with no signal means guessing, and guesses cost real work: rewriting pages that were never the problem, resubmitting URLs Google already has, publishing content to impress a crawler that has not arrived.&lt;/p&gt;

&lt;p&gt;Two traps nearly got me before the data did. The analytics script I use defaults to a rolling window whose upper bound is always three days in the past — on launch day, that window silently excluded launch day, and two runs on different dates were not comparable. Fixed by pinning explicit start and end dates. And a full 662-page URL inspection on day 0 would have returned 662 identical "unknown" results: zero discriminating power, real quota spent. I deferred the full batch to week two, when the results can actually rank pages against each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am watching next
&lt;/h2&gt;

&lt;p&gt;Week two brings the first full-batch inspection of all 662 URLs. The distribution — how many flip from unknown to discovered, how many from discovered to indexed — is the first real signal. If "Discovered – currently not indexed" shows up at scale, the response is boring: strengthen internal links to those page types, confirm the site is not accidentally slowing crawlers down, and wait. If "Crawled – currently not indexed" shows up, that is the quality signal, and it gets two weeks of observation before any attribution. Boring is the point. Indexing a new small site is a queue, and the job in week one is to make sure you are in it — then let it move.&lt;/p&gt;




&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/discovered-currently-not-indexed-real-data/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/discovered-currently-not-indexed-real-data/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Keyword Matrix: How I Find Gaps My Tool Sites Miss</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Sat, 10 Oct 2026 11:19:53 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/keyword-matrix-how-i-find-gaps-my-tool-sites-miss-3oh6</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/keyword-matrix-how-i-find-gaps-my-tool-sites-miss-3oh6</guid>
      <description>&lt;p&gt;I run a small family of free, no-login tool sites — eight of them are listed on my tools page — and each site lives or dies on search traffic. This morning I ran two keyword-matrix batches to find ranking gaps: clusters of keywords nobody in my family covers yet. One cluster, printable habit and savings trackers, died on the numbers. One came back from the dead: 79 legal-document keywords under KD 20, roughly 12,000 monthly searches combined, with cost-per-click figures up to $3.42. This post is the full run — the workflow, the real figures, and two decisions I would not have made by guessing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;SEO tooling and search behavior change fast. Every number in this post is a snapshot from early October 2026 — treat it as a workflow demo, not a forever-true dataset.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Guessing keywords failed me, so I stopped guessing
&lt;/h2&gt;

&lt;p&gt;Last week's case study about my PageSpeed scores started with ten keyword phrases I invented by intuition — phrases like "defer third party scripts". All ten returned zero search volume. Not low volume. Zero. A keyword tool can only report how people actually search, and people do not search like me.&lt;/p&gt;

&lt;p&gt;So the workflow changed. Instead of inventing phrases, I now mine three sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keywords that competitor sites already rank for&lt;/li&gt;
&lt;li&gt;questions people ask in communities around my topics&lt;/li&gt;
&lt;li&gt;word pools my own sites have not touched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Earlier this week I tore down timecalculator.net, the strongest competitor of my time card calculator site TallyPunch, layer by layer — page types, headings, FAQ schema. That teardown alone turned into 36 matrix pages. A competitor's keyword list is a map of proven demand: every word on it is a group of people already looking for something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix: topic rows, word-form columns, two probes per cell
&lt;/h2&gt;

&lt;p&gt;The matrix is a grid. Rows are topics or audiences — printable trackers, legal documents, paycheck calculators. Columns are word forms — generator, template, printable, calculator, maker. A cell is one seed phrase, like "will template" or "habit tracker printable".&lt;/p&gt;

&lt;p&gt;Each cell gets two probes through the Semrush keyword API, declared in a small seed file before every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phrase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"will template"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"filters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"volumeMin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kdMax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Probe A keeps every phrase-matched keyword with at least 100 monthly searches and keyword difficulty (KD) at or under 20 — the range a small new site can realistically rank for. Probe B is the watchlist: volume at least 500, KD up to 40 — too hard today, worth re-checking as domain authority grows.&lt;/p&gt;

&lt;p&gt;Both probes run on the US database, phrase match, in USD. Every batch ends with a six-dimension verdict: demand size, competitor presence, monetization, two standing rules of my own, and page format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision one: printable trackers died on the money dimension
&lt;/h2&gt;

&lt;p&gt;I wanted a new printable tracker site. Habit trackers, savings trackers, mood trackers — people search for these and print them, so the gap looked real. The run returned 13 keywords with a combined ~8,700 monthly searches, and the difficulties were gentle: savings tracker printable sits at 590 searches a month with KD 12; mood tracker printable is 110 with KD 18. The head word, habit tracker printable, carries 2,400 searches a month.&lt;/p&gt;

&lt;p&gt;The monetization dimension killed the idea. All 13 keywords are informational intent — people want a free PDF, not software. The CPC range, $0.42 to $1.20, is what advertisers pay for that traffic, and it means ad revenue alone cannot carry a standalone site.&lt;/p&gt;

&lt;p&gt;I almost built a whole site because I liked the words. The fix: the cluster moves into the word pool of WordRoost, my word-lists site, which already serves the same printable-and-list audience with the same ad infrastructure. No new site. Same keywords, different home.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision two: one word form returned zero rows, the other returned 79
&lt;/h2&gt;

&lt;p&gt;I wanted legal document keywords — wills, leases, eviction notices. The first run used the "generator" word form: "will generator", "lease agreement generator", and so on. The result was six empty CSV files. Zero rows across the board. My first thought was that the collection script was broken. It was not. My word was.&lt;/p&gt;

&lt;p&gt;So I moved one column sideways in the matrix — generator to template — and ran the cell again. "Will template" and its sibling seeds returned 79 keywords under KD 20 with a combined ~12,000 monthly searches, and this cluster is commercial: people want a document, and legal template services pay real money for that click.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document row&lt;/th&gt;
&lt;th&gt;Keywords under KD 20&lt;/th&gt;
&lt;th&gt;Strongest example&lt;/th&gt;
&lt;th&gt;CPC signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Will template&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;Oregon and Pennsylvania at KD 7&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lease agreement template&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;Maryland: $3.42, KD 13&lt;/td&gt;
&lt;td&gt;$2.80–3.42, the best in my whole project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power of attorney&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;New Jersey: 480/mo, KD 16, $1.63&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eviction notice&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Texas: 480/mo, KD 17, $1.55&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The big head words stay on the watchlist for now: lease agreement template alone carries 14,800 monthly searches, but KD 40 is out of reach for a new domain. The realistic ceiling is a state matrix — 50 states times five document types, roughly 250 pages — each page a real template download for one state.&lt;/p&gt;

&lt;p&gt;The lesson is now written in my playbook in bold: a falsified word form is not a falsified demand. Before killing a topic, move one column sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rescued cluster still has to pass the competitor gate
&lt;/h2&gt;

&lt;p&gt;Numbers alone do not launch a site. The next steps for the legal cluster are manual: check the actual search results for ten state-level words to confirm small sites can rank, and verify that competitors in this niche make money. My standing rule is no competitor revenue, no build.&lt;/p&gt;

&lt;p&gt;The rule has saved me before. In a four-direction verification round last week — timers, word-game helpers, recipe calculators, education worksheets — every direction turned out to have competitors with real revenue, from a $19.95-a-year ad-free membership at math-aids.com (taken from the site's own subscription code on 2026-09-26) to a stage timer SaaS reporting €20K MRR. Micro-clusters look small in a spreadsheet and still carry real businesses; a cluster with no revenue anywhere is usually a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell a past me
&lt;/h2&gt;

&lt;p&gt;Keep the probes cheap, keep the verdict boring, and write down why a cluster died — printable trackers died on money, not demand, and that difference decides whether a cluster gets merged, parked, or built.&lt;/p&gt;

&lt;p&gt;Third-party volumes and KD scores are estimates with snapshot dates; the workflow is the asset, the numbers are weather. The follow-up to this post will be the legal cluster's SERP check — ten state words, opened one by one, with what actually ranks.&lt;/p&gt;




&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/keyword-matrix-gap-analysis/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/keyword-matrix-gap-analysis/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Mobile PageSpeed Insights: Why Scores Stall</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:40:47 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/mobile-pagespeed-insights-why-scores-stall-14ha</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/mobile-pagespeed-insights-why-scores-stall-14ha</guid>
      <description>&lt;p&gt;&lt;em&gt;PageSpeed tooling and lab scores shift with every Chrome release. Every score in this post is a snapshot from September–early October 2026 — re-run your own measurements before acting on it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The main bottleneck on my static tool site was not my code. It was a single 177KB third-party analytics script, and moving its load out of the parse window took five pages from 83–96 to 99–100 on Google PageSpeed Insights (mobile), with LCP dropping from 3.5s to 1.5s. This post is the full case study: the measurement traps, the numbers, the code, and the second failure I did not see coming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick numbers (TallyPunch, official PSI mobile, September 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page weight: mine ~14KB, third parties ~180KB — 93% was not mine&lt;/li&gt;
&lt;li&gt;Worst page: 83 → 99, LCP 3.52s → 1.52s&lt;/li&gt;
&lt;li&gt;Cost: one script-loading change. Zero changes to my own code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Measurement behavior at PSI changes over time; treat the caching quirks below as "verify against current docs," not eternal law.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Set your baseline on official PageSpeed Insights, not local Lighthouse
&lt;/h2&gt;

&lt;p&gt;If you have ever wondered why your mobile PageSpeed Insights score is so hard to move while local Lighthouse says everything is fine — the two tools are measuring different worlds. The same page, at the same moment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Scores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local Lighthouse (my network, mainland China)&lt;/td&gt;
&lt;td&gt;39 / 63 / 57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official PageSpeed Insights (Google's servers)&lt;/td&gt;
&lt;td&gt;96 (three runs, identical)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Local Lighthouse is the open-source audit engine running on my machine over my network. PageSpeed Insights runs Lighthouse on Google's hardware over a simulated mobile network, blended with real-user data. On my restricted network, the third-party script's download and execution timing lands randomly inside or outside the measurement window, and the local variance becomes too large to optimize against. Google's official Core Web Vitals thresholds — LCP 2.5s, INP 200ms, CLS 0.1 — are the same numbers PSI scores against, so a baseline taken anywhere else is optimizing for the wrong target.&lt;/p&gt;

&lt;p&gt;One more trap: PSI serves cached responses for identical URL + strategy requests. Three "consistent" reads may be one cached response. Break the cache before trusting the median, and compare against an untouched control page from the same session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failing resource is usually not your own code
&lt;/h2&gt;

&lt;p&gt;My site's entire bill, from the PSI &lt;code&gt;third-parties-insight&lt;/code&gt; audit, looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;My code&lt;/th&gt;
&lt;th&gt;Third parties&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transfer bytes&lt;/td&gt;
&lt;td&gt;HTML + CSS + favicon ≈ 14KB&lt;/td&gt;
&lt;td&gt;gtag.js 177KB + CF beacon 10KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of 194KB total&lt;/td&gt;
&lt;td&gt;~7%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main-thread time&lt;/td&gt;
&lt;td&gt;~90ms&lt;/td&gt;
&lt;td&gt;gtag 171ms (786ms blocking, local)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fourteen kilobytes of my own code, zero forced reflows, zero layout shift. Nothing to fix there — which was almost disappointing, because I had a profiler ready and no villain of my own to catch.&lt;/p&gt;

&lt;p&gt;The diagnosis lives in three PSI audits, not in the headline score: &lt;code&gt;third-parties-insight&lt;/code&gt; shows who eats the main thread, &lt;code&gt;total-byte-weight&lt;/code&gt; shows who eats bytes, and &lt;code&gt;long-tasks&lt;/code&gt; shows the causal chain — in my case a long task starting at 2429ms against an LCP of 2551ms, the task sitting exactly on top of the paint moment. That is causation, not correlation.&lt;/p&gt;

&lt;p&gt;This also falsified, for this site shape, the common claim that TTFB is the upstream cause of LCP. My server-response-time was 1ms. On a small static site, the bottleneck is bytes and main-thread time, not the server. The TTFB chain holds or breaks depending on what kind of site you run — quantify before optimizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local A/B tests prove the mechanism, never the magnitude
&lt;/h2&gt;

&lt;p&gt;A controlled localhost A/B — same build, one variable, interleaved runs — cleanly isolated the mechanism. gtag static tag versus deferred load: TBT 343ms versus 0ms. Mechanism confirmed.&lt;/p&gt;

&lt;p&gt;But two structural blind spots make local numbers non-portable. First, the RTT blind spot: localhost has effectively zero round-trip time, while PSI's simulation charges 150ms per request. Both local variants capped at 99 with unchanged LCP; the same page scored 83 on PSI, and the entire gap lived in the RTT chain. "No change locally" does not mean "no change on PSI." Second, my restricted network inflated the third-party cost about fivefold — 786ms of blocking locally versus 171ms in PSI's environment. Direction agreed, magnitude did not.&lt;/p&gt;

&lt;p&gt;The rule I kept: local A/B answers "does the mechanism work," and official PSI answers "how much does it buy." Mixing the two wastes an afternoon, and I speak from experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defer third-party scripts until first interaction — and pin the delay with a test
&lt;/h2&gt;

&lt;p&gt;The fix keeps analytics semantics completely intact; it only moves the library's injection later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);}&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;G-XXXXXXX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// config stays inline — semantics unchanged&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// named constant, locked by a unit test&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;   &lt;span class="c1"&gt;// real users: no data lost&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                                            &lt;span class="c1"&gt;// passive visits: out of the window&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three load-bearing details. The &lt;code&gt;dataLayer&lt;/code&gt; stub and &lt;code&gt;config&lt;/code&gt; call stay inline in the document, so no tracking call site changes. Real users trigger the load on first interaction — click, key, touch, or scroll — so session data survives. The fallback fires 10 seconds after &lt;code&gt;load&lt;/code&gt; for passive visitors, pushing the script out of the lab measurement window.&lt;/p&gt;

&lt;p&gt;The honest cost: a visitor who reads passively and leaves within ten seconds is not counted. That trade was worth it for my content site, but it is a real trade, and I keep the books on it.&lt;/p&gt;

&lt;p&gt;The second failure mode is subtler: setting &lt;code&gt;FALLBACK_MS&lt;/code&gt; too low. A two-second delay drags the long task straight back into the TBT measurement window — the score collapses by points, and six months later nobody remembers why. My unit test asserts &lt;code&gt;FALLBACK_MS &amp;gt;= 8000&lt;/code&gt;, bans any static &lt;code&gt;&amp;lt;script async src=...&amp;gt;&lt;/code&gt; tag, and checks all four interaction triggers exist. The constant has a name because the number is a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  A site-wide fix patched into one page is debt, not optimization
&lt;/h2&gt;

&lt;p&gt;Then I audited a second site — TypeOnSkin, an interactive tattoo-font editor — and the main cause was the opposite of everything above. Not third parties. My own first-screen CSS, linked externally, render-blocking: &lt;code&gt;main.css&lt;/code&gt; wasted 151ms and &lt;code&gt;fonts.css&lt;/code&gt; wasted 451ms, and the external stylesheet delayed the font requests until CSS finished parsing. The fix script that inlined critical CSS only served the homepage.&lt;/p&gt;

&lt;p&gt;The homepage had already been cured of this exact disease one day earlier. The test that guards it even carried the diagnosis in its assertion message — "head must have no render-blocking external CSS (v1 externals caused mobile PSI 79)" — but that assertion only scanned &lt;code&gt;index.html&lt;/code&gt;. The cause of death was written inside the guardrail, and the guardrail watched one page. Every new page type re-caught the disease, legally, with nobody remembering the original outbreak.&lt;/p&gt;

&lt;p&gt;The general form, and the most valuable sentence in this post: &lt;strong&gt;a site-wide optimization implemented as a patch on one page is not an optimization — it is debt.&lt;/strong&gt; Every new page type re-runs it, and the rerun never remembers why.&lt;/p&gt;

&lt;p&gt;The repair had three parts, all required: inline critical CSS in the build script's shell itself rather than as a post-build injection step; one shared &lt;code&gt;inlineStyleTag()&lt;/code&gt; constructor used by both baking and post-processing; and a whole-site invariant, asserted across every HTML file, that the head contains zero &lt;code&gt;rel="stylesheet"&lt;/code&gt; links. Structure beats score for verifying recovery — scores get noisy, structure does not. All 24 pages now ship zero render-blocking stylesheets and zero CSS requests. (The font preload I almost added would have been negative value: the LCP element is system-ui body text, so preloading fonts only competes for bandwidth. Check what your LCP element actually is before optimizing for it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to improve Core Web Vitals: the ten steps, in order
&lt;/h2&gt;

&lt;p&gt;This is the checklist my next site will run from day one — a Core Web Vitals case study compressed into mechanics:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Take the baseline on official PSI.&lt;/strong&gt; Break its cache first, run five or more passes, take the median, and judge against an untouched control page from the same session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open &lt;code&gt;third-parties-insight&lt;/code&gt; and &lt;code&gt;total-byte-weight&lt;/code&gt;.&lt;/strong&gt; Identify who owns the bytes and the main thread before touching anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare &lt;code&gt;long-tasks&lt;/code&gt; start times against LCP.&lt;/strong&gt; A long task starting at the paint moment is the causal chain you are looking for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your own first-screen CSS too.&lt;/strong&gt; Look for external &lt;code&gt;rel="stylesheet"&lt;/code&gt; in the head and wasted milliseconds in &lt;code&gt;render-blocking-resources&lt;/code&gt; — the second most likely villain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defer third-party scripts&lt;/strong&gt; until first interaction, with an idle fallback well outside the measurement window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock the deferral in tests:&lt;/strong&gt; no static script tags, a named &lt;code&gt;FALLBACK_MS&lt;/code&gt; constant with a floor assertion, all interaction triggers present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build site-wide invariants into the build itself&lt;/strong&gt;, with one shared constructor and whole-site assertions — never a per-page patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the LCP element before any font or image strategy.&lt;/strong&gt; Preloading assets the LCP element does not use is negative value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-measure on PSI after every change.&lt;/strong&gt; Local numbers confirm mechanism only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect the side findings&lt;/strong&gt; — my pass also surfaced 16 color-contrast failures and security-header suggestions. The audit you ran anyway owes you the small print.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The results, before and after (official PSI, mobile)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;LCP&lt;/th&gt;
&lt;th&gt;TBT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;96 → 100&lt;/td&gt;
&lt;td&gt;2.55s → 1.52s&lt;/td&gt;
&lt;td&gt;100ms → 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time-card&lt;/td&gt;
&lt;td&gt;83 → 99&lt;/td&gt;
&lt;td&gt;3.52s → 1.52s&lt;/td&gt;
&lt;td&gt;0 → 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;biweekly&lt;/td&gt;
&lt;td&gt;83 → 99&lt;/td&gt;
&lt;td&gt;3.53s → 1.53s&lt;/td&gt;
&lt;td&gt;0 → 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;double-time&lt;/td&gt;
&lt;td&gt;90 → 99&lt;/td&gt;
&lt;td&gt;3.49s → 1.55s&lt;/td&gt;
&lt;td&gt;45ms → 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decimal&lt;/td&gt;
&lt;td&gt;90 → 100&lt;/td&gt;
&lt;td&gt;3.51s → 1.53s&lt;/td&gt;
&lt;td&gt;60ms → 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The site is TallyPunch, a free no-login time-card calculator suite for US small businesses. The 93%-third-party pattern should generalize to any small static site running analytics; the render-blocking relapse warns anyone maintaining more than one page type.&lt;/p&gt;




&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/pagespeed-insights-mobile-case-study/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/pagespeed-insights-mobile-case-study/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>seo</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Few Products Nobody Used: Honest Lessons</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:09:50 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/i-built-a-few-products-nobody-used-honest-lessons-57nm</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/i-built-a-few-products-nobody-used-honest-lessons-57nm</guid>
      <description>&lt;p&gt;I spent my first months as a full-time indie developer building small products that almost nobody used. Since October 2025 I have shipped a few tools, earned zero revenue from them, and learned four expensive lessons: building before validating demand, entering red oceans, over-engineering, and ignoring domains and search indexing. This post covers all four mistakes, plus why I am still not discouraged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do beginner indie developers pick "safe" ideas?
&lt;/h2&gt;

&lt;p&gt;At the beginning, I picked businesses I thought were easy to understand. A debugging tool, a relationship quiz — products with clear boundaries, where I did not even have to imagine the demand. Public cases already existed: other people had shipped similar things, and those products looked like they worked.&lt;/p&gt;

&lt;p&gt;With AI-assisted development, cloning an existing idea and adding my own twist took very little time. From a product perspective, this felt like a low-risk path: the direction was proven, the build was fast, and the remaining steps seemed to be "ship it, tell people, wait for users."&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you launch a product nobody wants?
&lt;/h2&gt;

&lt;p&gt;The products went live, and then nothing happened. No users, no feedback, and almost nobody knew these products existed. I did promote them on social media, and the effect was very limited.&lt;/p&gt;

&lt;p&gt;That silence was the part I had no plan for. I knew how to build; I had no idea how to be found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if you build before validating demand?
&lt;/h2&gt;

&lt;p&gt;Looking back, the whole process may have been backwards from step one. My approach was: decide "I can build this", build it, and then hope someone needs it. The order I lean toward now is the reverse: search keywords first, see what people are actually searching for, and only then decide what to build.&lt;/p&gt;

&lt;p&gt;"Build it and they will come" simply did not happen for me. This is my own experience, not a law — but it is the most expensive lesson of my first year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a red ocean market?
&lt;/h2&gt;

&lt;p&gt;A crowded market where many competitors already fight over the same customers. For a beginner it is usually a losing fight before you even enter, because promotion and features decide the outcome rather than effort alone. I only learned to check how crowded a topic is before entering it after paying the tuition — I had simply never known this check existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is over-engineering your first MVP a mistake?
&lt;/h2&gt;

&lt;p&gt;Yes, and I made exactly that mistake: I built the website "big" by default — login, payment, an admin panel, everything a real product should have. The problem was that I could not clearly explain what pain point the business actually solved. In hindsight, I was mostly using AI development to keep polishing something I wanted to build.&lt;/p&gt;

&lt;p&gt;In the end, nobody even knew the core function existed, so the login and admin panel meant nothing. AI makes "just build it" so easy that thinking first actually gets harder — wrong ideas get implemented more efficiently too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domains and search indexing: the infrastructure detours
&lt;/h2&gt;

&lt;p&gt;I picked cheap domains with no relation to the topic — they helped almost nothing. Search indexing was worse: I only knew "after launch, Google should find it", and I had no idea how to register a site with Google Search Console. I fumbled for months before I even found the door.&lt;/p&gt;

&lt;p&gt;The server side was equally new. My first cloud server was Tencent Cloud — powerful, but configuration and maintenance felt tedious. Later I came across Cloudflare, and the comparison made the earlier experience feel even more troublesome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am still not discouraged
&lt;/h2&gt;

&lt;p&gt;Even with no users and no product revenue, I genuinely enjoy this life. I take on some outsourcing work to cover living costs, and I would rather put that income back into indie experiments. Building itself is the reward, so the failures did not change that.&lt;/p&gt;

&lt;p&gt;I have come to think this might be a hidden threshold of indie development: if only success can keep you going, the long silent beginning is very hard to survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  My failure list and plan list (both still being validated)
&lt;/h2&gt;

&lt;p&gt;Failed so far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building before validating demand, and having nobody show up&lt;/li&gt;
&lt;li&gt;Entering a mature market without checking competition&lt;/li&gt;
&lt;li&gt;Building login, payment, and admin for zero users&lt;/li&gt;
&lt;li&gt;Using cheap unrelated domains, plus months of detour on search indexing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I plan to try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keyword-first topic selection: check demand and competition before building&lt;/li&gt;
&lt;li&gt;Treat social media as a long-term asset, while focusing on organic traffic&lt;/li&gt;
&lt;li&gt;Make the core value discoverable and usable first; defer other infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you judge real demand?&lt;/strong&gt; Search keywords: volume tells me demand exists, competition tells me how much room a beginner has. Works for me so far — not a law.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should a first product include login and payments?&lt;/strong&gt; My lesson says no. Ship the core value first; add the rest only when real usage asks for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Was the detour wasted time?&lt;/strong&gt; Not entirely — the engineering ability and the intuition for "what is a useless feature" are real. But this tuition is cheaper paid in advance.&lt;/p&gt;




&lt;p&gt;Originally published on my blog: &lt;a href="https://javaeye.dev/blog/first-products-nobody-used/" rel="noopener noreferrer"&gt;https://javaeye.dev/blog/first-products-nobody-used/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was created with the help of AI. AI was not used to write the content; it assisted only with translation and grammar checks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>indiehackers</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Generating Tournament Brackets in Vanilla JS: Seeding, Byes, and the Loser's Bracket</title>
      <dc:creator>Nan</dc:creator>
      <pubDate>Sun, 04 Oct 2026 10:04:29 +0000</pubDate>
      <link>https://dev.to/eye_java_420f1faa10ae8b86/generating-tournament-brackets-in-vanilla-js-seeding-byes-and-the-losers-bracket-11p8</link>
      <guid>https://dev.to/eye_java_420f1faa10ae8b86/generating-tournament-brackets-in-vanilla-js-seeding-byes-and-the-losers-bracket-11p8</guid>
      <description>&lt;p&gt;I run a small rec-league circuit, and every season someone redraws the playoff bracket by hand, gets a seed wrong, and quietly ruins the loser's bracket. So I sat down and wrote a bracket generator — plain JavaScript, no framework, no backend, no build step. The interesting part wasn't the UI. It was that three things everyone hand-waves in a bracket — seeding order, byes, and the loser's bracket — each hide an actual algorithmic decision.&lt;/p&gt;

&lt;p&gt;Here's what I ended up with, with the real code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Seeding is a recursion, not a shuffle
&lt;/h2&gt;

&lt;p&gt;A seeded single-elimination bracket for &lt;code&gt;s = 2^r&lt;/code&gt; teams has a fixed constraint: in every round, the sum of the two seeds playing each other should be &lt;code&gt;2^r + 1&lt;/code&gt; (1 vs 16, 2 vs 15, ... 8 vs 9 in a 16-team bracket). If you just shuffle or sort naively, you break that invariant and the #1 and #2 seeds can meet in round one.&lt;/p&gt;

&lt;p&gt;The standard fix is the "nested" construction: take the order for &lt;code&gt;s/2&lt;/code&gt;, then for each seed &lt;code&gt;x&lt;/code&gt; in it, emit &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;s + 1 - x&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;seedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;seedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// [1, 8, 4, 5, 2, 7, 3, 6]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round one pairs are adjacent entries, and every pairing sums to &lt;code&gt;s + 1&lt;/code&gt;. Four lines, and it's testable against a printed NCAA bracket forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Byes are slots, not special cases
&lt;/h2&gt;

&lt;p&gt;Non-power-of-two team counts (5, 6, 7, 9...) mean byes. The tempting approach is a bunch of &lt;code&gt;if (hasBye)&lt;/code&gt; branches scattered through the render code. That way lies pain, because a bye affects three things at once: who advances, which match doesn't exist, and what the "loser" of a bye is (nothing — there is no game, so nobody drops into the loser's bracket).&lt;/p&gt;

&lt;p&gt;I made a bye just another slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`W&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`W&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;loser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`L&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;adv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bye&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bye&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;loser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bye round has &lt;code&gt;id: null&lt;/code&gt;, &lt;code&gt;loser: null&lt;/code&gt;, and the advancing team's slot object is reused directly in the next round. The renderer treats it uniformly, and — this mattered more than I expected — the double-elimination logic stays correct for free, because "bye losers" simply never enter the losers pipeline. One representation, zero special cases downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The loser's bracket is a two-stage pipeline, not a tree
&lt;/h2&gt;

&lt;p&gt;This was the surprise. A winners bracket is a tree: every match feeds exactly one parent. A losers bracket is not any tree I could draw first and fill in later, because the set of teams arriving each round depends on who lost &lt;em&gt;this&lt;/em&gt; round in the winners bracket.&lt;/p&gt;

&lt;p&gt;What works is thinking of it as a pool with a two-stage pump, once per winners round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Minor stage&lt;/strong&gt;: the survivors currently in the losers pool play each other. If the count is odd, the last one gets a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Major stage&lt;/strong&gt;: the winners of those minor matches face the teams that just dropped out of the winners bracket this round. Pair them in order. If more teams dropped than survived, the extra drop-ins pass through to the next pool.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;survivors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// per winners round k:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pairUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// survivors[i] vs drops[i] for i &amp;lt; min(survivors, drops);&lt;/span&gt;
&lt;span class="c1"&gt;// leftovers (either side) carry into the next pool unchanged&lt;/span&gt;
&lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;majorWinners&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;minorSurvivors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;drops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that loop once per winners round and the pool drains to exactly one team: your losers bracket champion. Two properties fall out of the construction and are worth asserting in tests: a team is never asked to play twice in the same "round", and the total game count for &lt;code&gt;n&lt;/code&gt; teams is at most &lt;code&gt;2n - 1&lt;/code&gt; (one fewer if your format skips the grand-final reset).&lt;/p&gt;

&lt;p&gt;Byes from step 2 flow through here too — an odd-sized drop creates a carry-through, which is exactly the &lt;code&gt;drops.slice(n)&lt;/code&gt; term.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round robins are the easy one
&lt;/h2&gt;

&lt;p&gt;For completeness: a round robin schedule is the circle method. Fix team 1, rotate the rest by one each round, pair mirror positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an odd number of teams, append a dummy &lt;code&gt;null&lt;/code&gt; to the ring; whoever pairs with the dummy has a bye that round. After trying to be clever with modular arithmetic twice, the rotate-and-mirror version is the one I could still read in six months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why all of this is pure functions
&lt;/h2&gt;

&lt;p&gt;The whole engine — validation, the three builders, seed order, share-URL encode/decode — is pure functions over a config object. The same file runs in the browser, gets imported by the static-site baker, and is loaded directly by the Node test suite. No DOM, no fetch, no state. That one decision is why testing "5-team double elimination places byes correctly" is a one-liner instead of a browser automation job.&lt;/p&gt;

&lt;p&gt;I packaged all of this into a free, print-first bracket generator (single/double/round robin, 3–128 teams, byes placed automatically, nothing stored) if you want to see the output or steal the code: &lt;strong&gt;bracketree.com&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And if you draw yours on paper anyway: pencil in the loser's bracket. It will change.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>javascript</category>
      <category>algorithms</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
