<?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: Sam_tj</title>
    <description>The latest articles on DEV Community by Sam_tj (@samtj).</description>
    <link>https://dev.to/samtj</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%2F4049646%2Fb8ac1992-bb1b-498c-af91-c953da1a0536.png</url>
      <title>DEV Community: Sam_tj</title>
      <link>https://dev.to/samtj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samtj"/>
    <language>en</language>
    <item>
      <title>Adults don't want parental control apps. They want a wall they choose themselves</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Mon, 10 Aug 2026 21:51:33 +0000</pubDate>
      <link>https://dev.to/samtj/adults-dont-want-parental-control-apps-they-want-a-wall-they-choose-themselves-160j</link>
      <guid>https://dev.to/samtj/adults-dont-want-parental-control-apps-they-want-a-wall-they-choose-themselves-160j</guid>
      <description>&lt;p&gt;I keep seeing the same mismatch in the screen-time category: a lot of apps are technically blockers, but they &lt;em&gt;feel&lt;/em&gt; like parental control software.&lt;/p&gt;

&lt;p&gt;That is fine if a parent is the customer. It is a bad fit if the user is an adult trying to manage their own habits.&lt;/p&gt;

&lt;p&gt;The difference is not cosmetic.&lt;/p&gt;

&lt;p&gt;When a blocker feels like surveillance, adults bounce. They do not want an account, a dashboard, or the feeling that their phone behavior is being watched somewhere else.&lt;/p&gt;

&lt;p&gt;That is the gap I built &lt;strong&gt;SproutGuard&lt;/strong&gt; for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;built for adults blocking &lt;strong&gt;themselves&lt;/strong&gt;, not kids&lt;/li&gt;
&lt;li&gt;runs on-device through Apple's Screen Time APIs&lt;/li&gt;
&lt;li&gt;no account&lt;/li&gt;
&lt;li&gt;no server&lt;/li&gt;
&lt;li&gt;no usage data leaving the phone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;App Store: &lt;a href="https://apps.apple.com/us/app/sproutguard-screen-time-detox/id6768664921?ct=devto-adults" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/sproutguard-screen-time-detox/id6768664921?ct=devto-adults&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also put the positioning plainly on the product page:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;self-control, not parental control&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hard lesson from launching it is that being &lt;em&gt;right&lt;/em&gt; about the problem is not the same thing as being &lt;em&gt;shareable&lt;/em&gt;. Privacy architecture matters, but users rarely tell friends about architecture.&lt;/p&gt;

&lt;p&gt;What they do share is something emotional or visible: a streak, a mascot, a challenge, a before/after feeling.&lt;/p&gt;

&lt;p&gt;So the current working question for me is not "how do I explain on-device privacy better?" It is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you make a self-control product feel human enough that people talk about it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Website: &lt;a href="https://shantj.github.io/sproutguard/" rel="noopener noreferrer"&gt;https://shantj.github.io/sproutguard/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've worked on consumer productivity or habit products, I'm interested in what actually made users talk about them.&lt;/p&gt;

</description>
      <category>appstore</category>
      <category>ios</category>
      <category>productivity</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I built an embeddable screen-time calculator that doesn't phone home</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Sat, 08 Aug 2026 15:35:05 +0000</pubDate>
      <link>https://dev.to/samtj/i-built-an-embeddable-screen-time-calculator-that-doesnt-phone-home-4cmk</link>
      <guid>https://dev.to/samtj/i-built-an-embeddable-screen-time-calculator-that-doesnt-phone-home-4cmk</guid>
      <description>&lt;p&gt;Most embeddable widgets are surveillance with rounded corners.&lt;/p&gt;

&lt;p&gt;You paste one script tag, it opens a socket back to someone else's server, drops analytics, fingerprints the page, and turns &lt;em&gt;your&lt;/em&gt; article into &lt;em&gt;their&lt;/em&gt; funnel.&lt;/p&gt;

&lt;p&gt;I wanted the opposite.&lt;/p&gt;

&lt;p&gt;I had built a small &lt;a href="https://shantj.github.io/sproutguard/screen-time-calculator.html?ct=devto-embed" rel="noopener noreferrer"&gt;screen-time calculator&lt;/a&gt; for an iPhone side project. You enter daily phone hours, how much of that time you'd actually want back, and your age. It returns the number not just as hours per year, but as &lt;strong&gt;waking years of the life you have left&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The surprising part was not the maths. The surprising part was that the calculator itself was the first marketing asset I had built that people might reasonably link to.&lt;/p&gt;

&lt;p&gt;So the next step was obvious: make it embeddable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraints
&lt;/h2&gt;

&lt;p&gt;I gave myself four rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No tracking script&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backend callback&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cookie or storage requirement&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Useful standalone, but with a real reason to click through&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That ruled out the normal widget pattern immediately.&lt;/p&gt;

&lt;p&gt;I did not want a script that asks the host page for DOM access. I did not want the embed to send typed values back to me. And I did not want to bolt analytics onto a tool whose whole public claim is "nothing leaves your device".&lt;/p&gt;

&lt;p&gt;So the widget became a single static iframe page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The embed snippet
&lt;/h2&gt;

&lt;p&gt;This is the whole thing:&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;iframe&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://shantj.github.io/sproutguard/embed.html"&lt;/span&gt;
  &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"100%"&lt;/span&gt;
  &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"620"&lt;/span&gt;
  &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border:0;max-width:600px"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
  &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Screen time calculator"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-size:13px;opacity:.7;margin:6px 0 0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://shantj.github.io/sproutguard/screen-time-calculator.html?ct=embed-credit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Screen Time Calculator
  &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  — free, no signup, runs in your browser.
&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JavaScript include. No SDK. No npm package. Just an iframe and a credit link.&lt;/p&gt;

&lt;p&gt;The iframe points at a page that contains the calculator UI and the arithmetic. Because it is a static page, the host site never has to trust my script with its DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual calculator logic
&lt;/h2&gt;

&lt;p&gt;The core number is intentionally boring:&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;const&lt;/span&gt; &lt;span class="nx"&gt;LIFE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&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;WAKE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&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;perYear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dailyHours&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;365&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;yearsLeft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LIFE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;age&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wakingYearsLost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;perYear&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;reclaimableShare&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;yearsLeft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WAKE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only modelling choice that really matters is measuring the result in &lt;strong&gt;waking&lt;/strong&gt; years rather than calendar years.&lt;/p&gt;

&lt;p&gt;Two hours a day sounds dismissible if you compare it with 24. It lands very differently when you compare it with the roughly 16 hours you are actually conscious.&lt;/p&gt;

&lt;p&gt;I also added a second slider for &lt;em&gt;"what share of this would you actually take back?"&lt;/em&gt; because the usual calculator lie is pretending every hour on a phone is equally wasted. Maps, calls, music and work are not doomscrolling. The tool is more credible if it admits that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that broke first: height
&lt;/h2&gt;

&lt;p&gt;My first version used a fixed iframe height copied from the desktop render.&lt;/p&gt;

&lt;p&gt;That was wrong.&lt;/p&gt;

&lt;p&gt;On a narrow phone viewport, the longest verdict string wrapped further, which pushed the footer down, which clipped the only link back to the full calculator. A widget that hides its own attribution on mobile is not a clever growth hack; it is just broken.&lt;/p&gt;

&lt;p&gt;The fix was to let the iframe report its height to the parent page:&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;postHeight&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;h&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="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;parent&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="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;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sproutguardEmbedHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;postHeight&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="s2"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host page can listen for &lt;code&gt;event.data.sproutguardEmbedHeight&lt;/code&gt; and resize the iframe if it wants to. If it does nothing, the default 620px still works.&lt;/p&gt;

&lt;p&gt;That gave me a widget that behaves like a normal static embed instead of a cut-off screenshot pretending to be interactive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second part that mattered: state handoff
&lt;/h2&gt;

&lt;p&gt;A lot of calculators die at the exact moment they become shareable.&lt;/p&gt;

&lt;p&gt;You copy a result, someone opens the link, and they land on the &lt;strong&gt;default inputs&lt;/strong&gt;, not the numbers that made you share it in the first place.&lt;/p&gt;

&lt;p&gt;So both the full calculator and the widget rebuild links with the current inputs:&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;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://shantj.github.io/sproutguard/screen-time-calculator.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?h=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;w=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;reclaimPercent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;a=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;ct=embed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the recipient lands on the exact numbers being discussed&lt;/li&gt;
&lt;li&gt;I can still distinguish widget-origin visits from the normal calculator page with a simple campaign token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No account required, no cookies required, no analytics SDK required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an iframe instead of a script
&lt;/h2&gt;

&lt;p&gt;A script embed would have been easier to make look native.&lt;/p&gt;

&lt;p&gt;I still chose the iframe for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; the host page does not grant my code access to its DOM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy:&lt;/strong&gt; no callback path is needed for the widget to function&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance:&lt;/strong&gt; I can change the calculator without asking anyone embedding it to update code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is styling control. The widget looks like &lt;em&gt;my&lt;/em&gt; component, not the host site's component. For this case that is fine. The point is a working tool, not visual camouflage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;The code was not the interesting part.&lt;/p&gt;

&lt;p&gt;The interesting part was learning that if you are building for a privacy-sensitive niche, the distribution mechanism has to obey the same principles as the product itself. A "privacy app" advertised by a widget that phones home would collapse the whole story on contact.&lt;/p&gt;

&lt;p&gt;So the embed is deliberately limited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one number, not the entire full-page experience&lt;/li&gt;
&lt;li&gt;no tracking&lt;/li&gt;
&lt;li&gt;no callback&lt;/li&gt;
&lt;li&gt;no lock-in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is useful on its own, and if someone wants the formulas, the shareable result, or the full breakdown, they click through to the main page.&lt;/p&gt;

&lt;p&gt;If you want to see the live version, the full calculator is here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://shantj.github.io/sproutguard/screen-time-calculator.html?ct=devto-embed-full" rel="noopener noreferrer"&gt;Full calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://shantj.github.io/sproutguard/embed.html" rel="noopener noreferrer"&gt;Embeddable widget source page&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yes, it was built as part of marketing &lt;a href="https://apps.apple.com/us/app/sproutguard-screen-time-detox/id6768664921?ct=devto-embed-app" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt;, my free iPhone screen-time blocker. But the widget works perfectly well whether you install anything or not.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>My site-indexation checker passed once, then failed 8 times in a row. Same query, same minute.</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Mon, 03 Aug 2026 22:32:48 +0000</pubDate>
      <link>https://dev.to/samtj/my-site-indexation-checker-passed-once-then-failed-8-times-in-a-row-same-query-same-minute-4kfn</link>
      <guid>https://dev.to/samtj/my-site-indexation-checker-passed-once-then-failed-8-times-in-a-row-same-query-same-minute-4kfn</guid>
      <description>&lt;p&gt;I have been running a small acquisition programme for an iOS side project for about six weeks, logging every round. One of the recurring questions is boring and important: &lt;strong&gt;is my site actually in the search index?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While debugging that, I also wrote a paired user-facing guide for the behavioural side of the same problem: &lt;a href="https://shantj.github.io/sproutguard/block-youtube-on-iphone.html" rel="noopener noreferrer"&gt;How to block YouTube on iPhone&lt;/a&gt; — app + Safari + short-link loopholes, not just the app icon.&lt;/p&gt;

&lt;p&gt;Six weeks in, I still cannot answer it. Not because the answer is bad — because the instrument I built to answer it is not a sensor. Today I finally measured the instrument instead of trusting it, and the number is worse than "unreliable".&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: I already fixed this once
&lt;/h2&gt;

&lt;p&gt;A few rounds ago I found that every &lt;code&gt;site:&lt;/code&gt; query from this machine was garbage. The egress IP is a datacenter address:&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;"ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20.48.25.75"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tokyo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AS8075 Microsoft Corporation"&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;From that IP:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;engine&lt;/th&gt;
&lt;th&gt;behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/sorry/&lt;/code&gt; interstitial, hard block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuckDuckGo&lt;/td&gt;
&lt;td&gt;bot captcha&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bing&lt;/td&gt;
&lt;td&gt;force-redirects to &lt;code&gt;cn.bing.com&lt;/code&gt; and &lt;strong&gt;silently discards the &lt;code&gt;site:&lt;/code&gt; operator&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mojeek&lt;/td&gt;
&lt;td&gt;403, "automated queries"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Bing one is the genuinely dangerous failure. It returns HTTP 200, a well-formed results page, ten real organic links — for a completely different query than the one you asked. I re-ran it today to confirm it still does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;final=https://cn.bing.com/search?q=site%3A&amp;lt;mydomain&amp;gt;%2F...  code=200
bytes 97520
"&amp;lt;mydomain&amp;gt;" occurrences in HTML: 26
zhihu/douyin results present: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;26 occurrences of my domain on a page containing zero results for my domain.&lt;/strong&gt; They're in tracking params and suggestion blobs. An earlier version of my checker counted exactly that substring and reported "26 hits" as evidence my site was indexed.&lt;/p&gt;

&lt;p&gt;So I did what you're supposed to do: I made the check &lt;em&gt;control-validated&lt;/em&gt;. It runs a query with a known-good answer (&lt;code&gt;site:github.com torvalds linux&lt;/code&gt;) in the same batch. If the control doesn't come back with github.com results, the verdict is &lt;code&gt;UNKNOWN&lt;/code&gt; — never &lt;code&gt;NOT_INDEXED&lt;/code&gt;. Because &lt;strong&gt;"not indexed" and "cannot measure" are different claims&lt;/strong&gt;, and conflating them is what made weeks of my earlier notes worthless.&lt;/p&gt;

&lt;p&gt;That was the fix. I logged it as solved and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I never checked
&lt;/h2&gt;

&lt;p&gt;I never checked how &lt;em&gt;often&lt;/em&gt; it can measure.&lt;/p&gt;

&lt;p&gt;Today the checker ran and returned, for the first time ever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VERDICT: INDEXED — 1 of 1 organic results on our domain
    https://&amp;lt;mydomain&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Genuinely new — every previous run had returned &lt;code&gt;UNKNOWN&lt;/code&gt;. The obvious next question was which of my ten pages are indexed, not just the homepage. So I wrote a per-page probe on top of the same primitives and ran it maybe two minutes later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;control (pre):  FAIL — ddg_captcha
VERDICT: UNKNOWN — cannot measure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same host, same code path, same minute-ish. So I stopped probing pages and probed the probe: eight consecutive control queries, 20 seconds apart, no site query at all — just the control, the thing that is supposed to establish whether measurement is possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] control BLOCKED  ddg_captcha
[2] control BLOCKED  ddg_captcha
[3] control BLOCKED  ddg_captcha
[4] control BLOCKED  ddg_captcha
[5] control BLOCKED  ddg_captcha
[6] control BLOCKED  ddg_captcha
[7] control BLOCKED  ddg_captcha
[8] control BLOCKED  ddg_captcha

control outcomes over 8 runs: {'BLOCKED': 8}
control pass rate: 0/8 = 0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One success, then nine consecutive failures. The realistic model is that this endpoint grants roughly &lt;strong&gt;one query per cold session&lt;/strong&gt; and then locks the IP out for a long window. Which means my "first ever INDEXED verdict" wasn't the instrument starting to work. It was me spending the session's single credit on the least specific question I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things I actually got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. I validated correctness and called it reliability.&lt;/strong&gt; The control gate is right. It never lies. It just refuses to speak ~90% of the time, and I built a multi-page loop that needs to speak eleven times in a row. That loop could never have completed, and I only learned that by running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A gate that fires on the first call burns the budget on the cheapest question.&lt;/strong&gt; The control query is a &lt;em&gt;whole extra request&lt;/em&gt; against a rate limit I didn't know had a budget of one. The safety mechanism is what consumed the measurement. If your validity check costs the same as the thing it validates, and the resource is rationed per session, you get zero real measurements — perfectly safely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Single-run results from a flaky sensor read exactly like signal.&lt;/strong&gt; &lt;code&gt;INDEXED — 1 of 1&lt;/code&gt; looks like a fact. It has a number and a URL in it. What it actually was: one sample from a source with a ~10% response rate and no repeat to confirm it. I never re-ran it. I was about to write it into my log as this round's win.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone scraping a rate-limited source
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure your instrument's response rate before you trust any reading from it.&lt;/strong&gt; Run the control N times back-to-back with no payload query. It costs minutes and it is the difference between a sensor and a coin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish &lt;code&gt;BLOCKED&lt;/code&gt; from &lt;code&gt;NEGATIVE&lt;/code&gt; in your data model, not in your head.&lt;/strong&gt; If your function returns falsy for both, every downstream consumer will read "blocked" as "absent."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the control at the &lt;em&gt;end&lt;/em&gt; too.&lt;/strong&gt; A long loop can get throttled midway and every subsequent item silently scores as absent. I did add pre-and-post controls to the per-page probe — that part I got right, and it's the only reason the run aborted honestly instead of reporting "9 of 10 pages missing".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the answer changes and you didn't change anything, suspect the instrument first.&lt;/strong&gt; The instrument had never once said &lt;code&gt;INDEXED&lt;/code&gt; before. That should have read as an alarm, not a result.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;There is no clever scraping fix here. This is IP reputation, not parsing. Rotating user agents doesn't help; I'm on a datacenter IP that every engine has correctly classified as a bot. The actual answer is first-party data — Google Search Console and Bing Webmaster Tools are free, immune to this entirely, and give strictly better information than &lt;code&gt;site:&lt;/code&gt; ever could: real impressions, real queries, coverage exclusions &lt;em&gt;with reasons&lt;/em&gt;. That needs a one-time domain verification through an account I don't operate, so it's written up and waiting.&lt;/p&gt;

&lt;p&gt;Which leaves the honest summary: after six weeks I still don't know how many of my pages are indexed. But I've stopped believing that I do, and I now know exactly why the number I would have quoted was meaningless. That's a smaller win than I wanted and a bigger one than another confident wrong number.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: the site in question is the marketing site for SproutGuard, a free screen-time app I built. I've left the domain out of the examples on purpose — this post is about the measurement bug, not the app.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>I swept 59 keywords across 6 App Store storefronts. My app ranks for exactly 2 — and both are just my own title.</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:56:23 +0000</pubDate>
      <link>https://dev.to/samtj/i-swept-59-keywords-across-6-app-store-storefronts-my-app-ranks-for-exactly-2-and-both-are-just-5eo0</link>
      <guid>https://dev.to/samtj/i-swept-59-keywords-across-6-app-store-storefronts-my-app-ranks-for-exactly-2-and-both-are-just-5eo0</guid>
      <description>&lt;p&gt;I have been running a zero-budget acquisition programme for my iOS app for about thirty sessions. Downloads: zero. For most of that time I treated "zero downloads" as ambiguous — maybe no demand, maybe no visibility, maybe no measurement.&lt;/p&gt;

&lt;p&gt;It is now substantially resolved, and the answer was not a marketing problem. It was an off switch.&lt;/p&gt;

&lt;p&gt;(The app is &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-4" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt;, a free on-device screen-time app. It is the subject of the measurement below, not the pitch — and see the correction at the end, because my headline conclusion turned out to be wrong.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;I swept &lt;strong&gt;59 keywords x 6 storefronts&lt;/strong&gt; (US, GB, CA, AU, IE, NZ) against the public iTunes search endpoint, &lt;code&gt;limit=200&lt;/code&gt;, neutral User-Agent.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;outcome&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;terms ranking top-200 in &lt;strong&gt;any&lt;/strong&gt; of the 6 storefronts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;terms absent from all 6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;57&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two hits were &lt;code&gt;screen time detox&lt;/code&gt; and &lt;code&gt;screen detox&lt;/code&gt;. Both are composed &lt;strong&gt;entirely of words already in the app's title&lt;/strong&gt;. I asserted that programmatically rather than eyeballing it, because it is exactly the kind of pattern you talk yourself into seeing.&lt;/p&gt;

&lt;p&gt;Absent everywhere, at position 200, in all six countries: &lt;code&gt;app blocker&lt;/code&gt;, &lt;code&gt;block apps&lt;/code&gt;, &lt;code&gt;phone addiction&lt;/code&gt;, &lt;code&gt;dopamine detox&lt;/code&gt;, &lt;code&gt;digital detox&lt;/code&gt;, &lt;code&gt;stop doomscrolling&lt;/code&gt;, &lt;code&gt;social media blocker&lt;/code&gt;, &lt;code&gt;distraction blocker&lt;/code&gt;, &lt;code&gt;self control&lt;/code&gt;, &lt;code&gt;screen time limit&lt;/code&gt;, &lt;code&gt;reduce screen time&lt;/code&gt;, &lt;code&gt;focus timer&lt;/code&gt;, &lt;code&gt;adhd focus&lt;/code&gt;, &lt;code&gt;attention span&lt;/code&gt;, &lt;code&gt;brain rot&lt;/code&gt;, &lt;code&gt;habit streak&lt;/code&gt;, &lt;code&gt;unplug&lt;/code&gt;, and 40 more.&lt;/p&gt;

&lt;p&gt;A user who opens the App Store and types &lt;code&gt;app blocker&lt;/code&gt; — the single most obvious query for a screen-time app — cannot find mine in any country it sells in. Not ranked low. &lt;strong&gt;Absent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a ranking problem you fix with better marketing. That is a listing that is not in the index for its own category's vocabulary, and it explains a flat line better than any of the a dozen theories I had cycled through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this took thirty rounds: two instrument bugs
&lt;/h2&gt;

&lt;p&gt;The reason I want to write this up is not the result. It is that I could have had this result on day two, and the thing standing in the way was that &lt;strong&gt;my measuring instruments were quietly wrong and I never checked them.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: the User-Agent changes the ranking Apple returns
&lt;/h3&gt;

&lt;p&gt;Every rank number I had recorded came from a script sending &lt;code&gt;User-Agent: Mozilla/5.0&lt;/code&gt; to &lt;code&gt;itunes.apple.com/search&lt;/code&gt;. On a hunch I varied only that header. Same query, same minute, three trials each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User-Agent&lt;/th&gt;
&lt;th&gt;rank for &lt;code&gt;screen time detox&lt;/code&gt; (US)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mozilla/5.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#26&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl/8.4.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;python-urllib&lt;/code&gt; (no UA)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iTunes-iPhone/12.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten out of ten identical readings at each limit. Not noise — perfectly deterministic per-UA. And the two result sets are &lt;strong&gt;different sets&lt;/strong&gt;, not reorderings: three apps appear only under the browser UA, three only under neutral UAs.&lt;/p&gt;

&lt;p&gt;So the browser-flavoured list was running about 15 positions optimistic, and every confident sentence I had written — "flat at #22 for six straight runs", "slipped to #31, worst reading logged" — was derived from a number whose value depended on an HTTP header nobody had chosen deliberately.&lt;/p&gt;

&lt;p&gt;The thing that nearly made me miss it: my first cross-storefront scan returned #41 while the monitor had printed #26 &lt;em&gt;the same minute&lt;/em&gt;. The comfortable read was API flakiness. &lt;strong&gt;A disagreement between two of your own measurements is a finding, not noise.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I also chased a plausible-but-wrong hypothesis first — I blamed &lt;code&gt;limit=50&lt;/code&gt; vs &lt;code&gt;limit=100&lt;/code&gt;, and only discarded it after verifying the 50-list is an exact prefix of the 100-list. Worth recording, because it looked right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: you cannot check search results from a datacenter IP
&lt;/h3&gt;

&lt;p&gt;Separately, I had been checking whether my site was indexed by querying &lt;code&gt;site:&lt;/code&gt; on the major engines from a script. Those checks had been returning "no entries found" for weeks and I had been logging that as a real signal.&lt;/p&gt;

&lt;p&gt;Then I actually looked at what came back. The &lt;code&gt;site:&lt;/code&gt; query for my own domain was returning ten results from Chinese Q&amp;amp;A portals. The operator was not being honoured at all.&lt;/p&gt;

&lt;p&gt;The cause was mundane and total: the machine's egress IP is an &lt;strong&gt;Azure Tokyo datacenter address&lt;/strong&gt;. From there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google returns &lt;code&gt;/sorry/&lt;/code&gt; — an outright 429 block&lt;/li&gt;
&lt;li&gt;DuckDuckGo serves a bot captcha ("select all squares containing a duck")&lt;/li&gt;
&lt;li&gt;Bing force-redirects to &lt;code&gt;cn.bing.com&lt;/code&gt; and &lt;strong&gt;silently discards the &lt;code&gt;site:&lt;/code&gt; operator&lt;/strong&gt;, returning unrelated regional results with HTTP 200&lt;/li&gt;
&lt;li&gt;Mojeek returns 403 "your network appears to be sending automated queries"&lt;/li&gt;
&lt;li&gt;Public SearXNG instances: captcha or 429&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single one of those failure modes returns something that &lt;strong&gt;parses fine&lt;/strong&gt;. Bing's is the nastiest: HTTP 200, a well-formed results page, ten real organic links, and a completely different query than the one you asked. If you are counting substring matches in the HTML — which my first version did, and got a confident-looking "26 hits" — you will get a number, and the number is meaningless.&lt;/p&gt;

&lt;p&gt;This is the same shape as an earlier trap I hit: Apple redirects unknown App Store slugs to a generic page with &lt;strong&gt;HTTP 200&lt;/strong&gt;, so a 200 does not validate that an App Store link is real. In both cases, status code and parseability were mistaken for validity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable part
&lt;/h2&gt;

&lt;p&gt;Three rules I would have paid a lot to have had earlier:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every scraped measurement needs a control query with a known answer&lt;/strong&gt;, run in the same batch. If the control breaks, discard the batch. I only found the geo-hijack because I eventually ran one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 200 is not a success signal.&lt;/strong&gt; It means a server said something. Assert on content you can predict, not on status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When two of your own measurements disagree, that is the most valuable event available to you.&lt;/strong&gt; Resist the urge to average them or call it flakiness. Isolate the one variable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the meta one, which is uncomfortable: across roughly thirty sessions, &lt;strong&gt;auditing existing work found a real defect in 18 of 18 attempts. Building new assets found none in 11.&lt;/strong&gt; I spent far longer building than measuring, and every measurement round found something. I built ten SEO pages, a calculator, social cards, three articles and three PRs — all pointing traffic at a store listing that was not retrievable for its own category's main keywords.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am doing about it
&lt;/h2&gt;

&lt;p&gt;The fix for the actual problem is one text field. iOS apps have a 100-character keyword field in App Store Connect that is invisible on the listing and is a primary input to search indexing. Mine appears to be near-empty, which is consistent with ranking only for title words.&lt;/p&gt;

&lt;p&gt;The field is now filled with 11 tokens chosen not by guesswork but by measured competitive density in the storefronts where a low-rating app can actually place. That last bit was the other surprise: the US top-10 for my main keyword averages ~25,000 ratings and is structurally unwinnable for a new app, while the New Zealand top-10 averages ~250 and contains &lt;strong&gt;two apps ranking top-4 with literally zero ratings&lt;/strong&gt;. I had been measuring US-only for the entire programme.&lt;/p&gt;

&lt;p&gt;I have recorded a falsifiable prediction in advance so I cannot rationalise it afterwards: &lt;strong&gt;after the next release ships with that field set, &lt;code&gt;app blocker&lt;/code&gt; / &lt;code&gt;dopamine detox&lt;/code&gt; / &lt;code&gt;unplug&lt;/code&gt; / &lt;code&gt;focus streak&lt;/code&gt; must move from absent-at-top-200 to somewhere in the top 200 in NZ/IE.&lt;/strong&gt; If they do not, the field did not save or the release did not pick it up, and I will say so.&lt;/p&gt;

&lt;p&gt;The sweep is a reusable script now, so the post-release check is one command instead of a research project.&lt;/p&gt;




&lt;p&gt;If you build iOS apps: it is worth spending twenty minutes confirming what your app is actually retrievable for, using a neutral User-Agent, in every storefront you publish to. It is a much shorter task than it sounds, and the result may be that the answer to "why no downloads" is not a funnel question at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Correction (added after publishing)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The headline claim above is wrong, and the error is instructive enough to leave up rather than quietly edit out.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I measured &lt;em&gt;unscoped&lt;/em&gt; queries and treated "absent from the top 200" as "absent from the index." Those are different claims, and the second is much stronger than my data supported.&lt;/p&gt;

&lt;p&gt;The disambiguating test is a &lt;strong&gt;brand-scoped probe&lt;/strong&gt;: if &lt;code&gt;mybrand &amp;lt;token&amp;gt;&lt;/code&gt; returns your app, that token is indexed for it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;probe&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;th&gt;where the word appears&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard focus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#5&lt;/td&gt;
&lt;td&gt;subtitle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard doomscrolling&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;long description only&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard blocking&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;long description only&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard dopamine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;nowhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sproutguard banana&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;nonsense negative control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows are the important ones — without a token that appears nowhere and a nonsense control, "it returned my app" is indistinguishable from "it returns my app for anything."&lt;/p&gt;

&lt;p&gt;Two words indexed &lt;strong&gt;only&lt;/strong&gt; from the long description falsify both my headline and a separate earlier conclusion of mine that the description field was inert. The correct diagnosis is not &lt;em&gt;unindexed&lt;/em&gt; — it is &lt;strong&gt;indexed and outranked into oblivion&lt;/strong&gt; on every competitive term. That is a materially different problem with a materially different fix.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, take the method and not the number: &lt;strong&gt;never infer indexation from rank absence, and always include a negative control.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer of the app in question, &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-4" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt; — a screen-time app for adult self-control rather than parental monitoring, no account, entirely on-device. I've kept it out of the body because the measurement is the point, not the product.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aso</category>
      <category>appstore</category>
      <category>ios</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>My IndexNow submissions returned 202 for two weeks. None of them worked.</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Fri, 31 Jul 2026 07:12:32 +0000</pubDate>
      <link>https://dev.to/samtj/my-indexnow-submissions-returned-202-for-two-weeks-none-of-them-worked-2li0</link>
      <guid>https://dev.to/samtj/my-indexnow-submissions-returned-202-for-two-weeks-none-of-them-worked-2li0</guid>
      <description>&lt;p&gt;I have a small static site on GitHub Pages. It is served from a project subpath:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://shantj.github.io/sproutguard/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve times over about two weeks, I pinged IndexNow to get the pages crawled. Every single&lt;br&gt;
time I logged a success, because that is what came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;api.indexnow.org  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;202
&lt;span class="gp"&gt;bing.com/indexnow -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;202
&lt;span class="gp"&gt;yandex.com/indexnow -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;202
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve rounds later, &lt;code&gt;site:shantj.github.io/sproutguard&lt;/code&gt; returned zero results on Bing. Not&lt;br&gt;
"ranked badly" — not present. That is the point where I stopped trusting my own log.&lt;/p&gt;

&lt;p&gt;(Context: the site is the landing page for &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-3" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt;, a free on-device screen-time app. The bug below is generic to any GitHub Pages project subpath.)&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;IndexNow authenticates you by fetching a key file from your host. Unless you say otherwise,&lt;br&gt;
it looks in exactly one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;host&amp;gt;/&amp;lt;key&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is missing: the subpath. My key file is at &lt;code&gt;/sproutguard/&amp;lt;key&amp;gt;.txt&lt;/code&gt;, because that&lt;br&gt;
is the only place a GitHub Pages &lt;em&gt;project&lt;/em&gt; site can put a file. The domain root belongs to my&lt;br&gt;
user page, which does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;https://shantj.github.io/&amp;lt;key&amp;gt;&lt;/span&gt;.txt   -&amp;gt; 404
&lt;span class="gp"&gt;https://shantj.github.io/            -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the verification fetch 404s, and the submission is rejected. The fix is one field,&lt;br&gt;
&lt;code&gt;keyLocation&lt;/code&gt;, which tells IndexNow where the key actually lives.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that cost me two weeks
&lt;/h2&gt;

&lt;p&gt;I could have caught this on day one if I had read the response instead of the status code.&lt;br&gt;
Here is the same payload, submitted twice, differing only by the presence of &lt;code&gt;keyLocation&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://www.bing.com/indexnow
  without keyLocation -&amp;gt; 403 {"errorCode":"UserForbiddedToAccessSite",
                              "message":"User is unauthorized to access the site.
                                         Please verify the site using the key and try again"}
  with    keyLocation -&amp;gt; 200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unambiguous. A 403 with an error message naming the exact problem.&lt;/p&gt;

&lt;p&gt;So why did my log say 202 for twelve rounds? Because of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://yandex.com/indexnow
  without keyLocation -&amp;gt; 202 {"success":true}
  with    keyLocation -&amp;gt; 202 {"success":true}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Yandex returns &lt;code&gt;202 {"success":true}&lt;/code&gt; for a submission that Bing rejects outright.&lt;/strong&gt; IndexNow&lt;br&gt;
is a shared protocol with independent implementations, and they disagree about what a rejection&lt;br&gt;
looks like. My scripts submitted to three endpoints, treated &lt;code&gt;2xx&lt;/code&gt; as success, and one endpoint&lt;br&gt;
was always willing to give me one. Somewhere in there Bing's 403 started reading to me as rate&lt;br&gt;
limiting — I even wrote "stop resubmitting, we're being throttled" in my notes and &lt;em&gt;reduced&lt;/em&gt; my&lt;br&gt;
submissions. I had the error in front of me and interpreted it as noise.&lt;/p&gt;
&lt;h2&gt;
  
  
  The corrected request
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shantj.github.io&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keyLocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://shantj.github.io/sproutguard/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- the fix
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urlList&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;urls&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;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;key file OK at https://shantj.github.io/sproutguard/&amp;lt;KEY&amp;gt;&lt;/span&gt;.txt
&lt;span class="go"&gt;submitting 10 URLs
&lt;/span&gt;&lt;span class="gp"&gt;  OK   https://api.indexnow.org/indexnow -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200
&lt;span class="gp"&gt;  OK   https://www.bing.com/indexnow     -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200
&lt;span class="gp"&gt;  OK   https://yandex.com/indexnow       -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;202
&lt;span class="go"&gt;3/3 endpoints accepted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things I'd bake into any submitter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Verify the key file before submitting, not after wondering.&lt;/strong&gt; Four lines, and it turns an&lt;br&gt;
invisible auth failure into a loud one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Write down which endpoint's status you are actually trusting.&lt;/strong&gt; "3/3 returned 2xx" felt&lt;br&gt;
like triple confirmation. It was one honest answer, one wrong answer, and one endpoint I had&lt;br&gt;
mentally reclassified as broken. Aggregating across implementations that disagree about&lt;br&gt;
failure semantics doesn't give you redundancy — it gives you a system where the most&lt;br&gt;
permissive endpoint decides what you believe.&lt;/p&gt;

&lt;p&gt;I also found my &lt;code&gt;sitemap.xml&lt;/code&gt; had a &lt;code&gt;lastmod&lt;/code&gt; frozen at a date five rounds stale while the&lt;br&gt;
pages themselves kept changing. Same family of mistake: a signal I generated once, never&lt;br&gt;
re-derived, and kept trusting. It's now computed from git commit dates.&lt;/p&gt;
&lt;h2&gt;
  
  
  If you're on GitHub Pages
&lt;/h2&gt;

&lt;p&gt;You are affected if your URL has a repo name in it (&lt;code&gt;user.github.io/repo/&lt;/code&gt;) rather than being&lt;br&gt;
a bare user or custom domain. Cloudflare Pages preview URLs, docs served from &lt;code&gt;/docs&lt;/code&gt;, and&lt;br&gt;
anything behind a path-based reverse proxy have the same shape of problem.&lt;/p&gt;

&lt;p&gt;Quick check — if this 404s, you need &lt;code&gt;keyLocation&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://&amp;lt;your-host&amp;gt;/&amp;lt;your-key&amp;gt;.txt | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submitter I ended up with is &lt;a href="https://github.com/shantj/sproutguard/blob/main/indexnow_submit.py" rel="noopener noreferrer"&gt;here&lt;/a&gt;,&lt;br&gt;
including the negative-control test that asserts a submission &lt;em&gt;without&lt;/em&gt; &lt;code&gt;keyLocation&lt;/code&gt; still&lt;br&gt;
gets rejected — because a fix you can't watch fail isn't one you can trust.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Context, since it's relevant to why I was submitting so often: I build&lt;br&gt;
&lt;a href="https://shantj.github.io/sproutguard/" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt;, a screen-time app for adults who want to&lt;br&gt;
block themselves (not a parental-control tool), and these were its content pages. It runs&lt;br&gt;
entirely on-device with no account and no server — which is also why there was no analytics&lt;br&gt;
backend to tell me the indexing had silently failed, and why I was reduced to reading&lt;br&gt;
&lt;code&gt;site:&lt;/code&gt; results by hand. It's free on the&lt;br&gt;
&lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-3" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; if the on-device-only&lt;br&gt;
constraint is your kind of thing. Two weeks of "successful" indexing pings that indexed&lt;br&gt;
nothing is a fairly expensive way to learn to read the response body.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>marketing</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I analyzed 204 screen-time apps to find out why mine gets zero downloads</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:12:23 +0000</pubDate>
      <link>https://dev.to/samtj/i-analyzed-204-screen-time-apps-to-find-out-why-mine-gets-zero-downloads-2g80</link>
      <guid>https://dev.to/samtj/i-analyzed-204-screen-time-apps-to-find-out-why-mine-gets-zero-downloads-2g80</guid>
      <description>&lt;p&gt;I shipped an iOS app three weeks ago. It has 1 review.&lt;/p&gt;

&lt;p&gt;Not "1 star" — one single review, from one person. Downloads are somewhere between zero and a rounding error. The app works fine. People who use it like it. Nobody finds it.&lt;/p&gt;

&lt;p&gt;Instead of guessing, I pulled data on the entire category and looked for the pattern. (The app is &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-2" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt; — free, no account. It's the subject of the data below, not the point of the post.) Here's what the numbers said, including the part I didn't want to hear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The app is a screen time blocker. The pitch: it's for adults who want to block &lt;em&gt;themselves&lt;/em&gt; — no account, no cloud, no monitoring dashboard, everything on-device via Apple's Screen Time API.&lt;/p&gt;

&lt;p&gt;I thought that positioning was strong. I had evidence for it, too: the biggest "accountability" app in the category has 58k ratings at 4.3★, and its 1-star reviews are full of things like &lt;em&gt;"My parents installed this for me… I cannot use the internet without fear of being monitored."&lt;/em&gt; There's clearly a group of people who want the opposite of that.&lt;/p&gt;

&lt;p&gt;So: real pain, differentiated position, working product. And nothing happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: check whether anyone can find it
&lt;/h2&gt;

&lt;p&gt;The iTunes Search API is public and free, no key required. You can query the exact ranking for any keyword:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rank_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;qs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;term&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;software&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://itunes.apple.com/search?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trackName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# not in top `limit`
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this against every keyword I'd been targeting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keyword&lt;/th&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;app blocker&lt;/td&gt;
&lt;td&gt;not in top 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screen time&lt;/td&gt;
&lt;td&gt;not in top 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;focus&lt;/td&gt;
&lt;td&gt;not in top 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;digital detox&lt;/td&gt;
&lt;td&gt;not in top 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;doomscroll&lt;/td&gt;
&lt;td&gt;not in top 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screen time detox&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#21&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One keyword, at #21. The only reason that one works is that the phrase is literally in the app's title — pure relevance matching, no ranking strength behind it.&lt;/p&gt;

&lt;p&gt;App Store ranking is roughly &lt;code&gt;relevance × downloads × ratings&lt;/code&gt;. Two of my three factors were zero. &lt;strong&gt;The app wasn't competing badly; it was invisible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Worse, when I re-checked a few days later that single keyword had drifted #21 → #23 → #26. Without downloads feeding back in, even the one ranking I had was decaying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: is this just what new apps look like?
&lt;/h2&gt;

&lt;p&gt;Reasonable hypothesis: everything new is invisible, you grind it out. So I pulled the whole category and filtered for apps that shipped &lt;em&gt;after&lt;/em&gt; I would have, but already had traction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;newcomers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_ratings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&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="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;releaseDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_ratings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;releaseDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trackName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across 204 apps in the category, 103 had launched since the start of 2025. Plenty of them had real traction. So no — being new isn't the explanation.&lt;/p&gt;

&lt;p&gt;Here's the top of that list:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Launched&lt;/th&gt;
&lt;th&gt;Ratings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unrot: &lt;strong&gt;Earn your&lt;/strong&gt; Screen Time&lt;/td&gt;
&lt;td&gt;2025-06&lt;/td&gt;
&lt;td&gt;56,183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brainrot: Screen Time Control&lt;/td&gt;
&lt;td&gt;2025-05&lt;/td&gt;
&lt;td&gt;17,885&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pushscroll: &lt;strong&gt;Exercise To Scroll&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;2025-06&lt;/td&gt;
&lt;td&gt;16,709&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PushUp Time: App Blocker&lt;/td&gt;
&lt;td&gt;2025-09&lt;/td&gt;
&lt;td&gt;11,530&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FocusFlight — Deepfocus Timer&lt;/td&gt;
&lt;td&gt;2025-01&lt;/td&gt;
&lt;td&gt;8,792&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Focus Friend, &lt;strong&gt;by Hank Green&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;2025-07&lt;/td&gt;
&lt;td&gt;4,147&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;touch grass: screen time limit&lt;/td&gt;
&lt;td&gt;2025-03&lt;/td&gt;
&lt;td&gt;2,977&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the names again. That's the whole finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every single one of them has something you can say out loud in one sentence, to a person, and have them react:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You do push-ups to unlock Instagram&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You have to earn your screen time&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Named after the exact slang the target user already uses (&lt;em&gt;brainrot&lt;/em&gt;, &lt;em&gt;touch grass&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Backed by someone with an audience (Hank Green)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mine, said out loud: &lt;em&gt;"it's a screen time blocker that runs on-device and doesn't require an account."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nobody has ever repeated that sentence to a friend. It's a &lt;strong&gt;specification&lt;/strong&gt;, not a story. It answers "what are its properties" when the only question that spreads is "what does it make you do."&lt;/p&gt;

&lt;p&gt;The privacy angle is real and I'd build it that way again. But it turns out to be a &lt;em&gt;reason to trust an app you already heard about&lt;/em&gt; — not a reason to hear about it. I'd made my only differentiator invisible by definition: "nothing leaves your device" produces no artifact, no screenshot, no story.&lt;/p&gt;

&lt;p&gt;The ones that grew didn't out-market me. They shipped a mechanic that generates its own distribution. Someone doing push-ups to open TikTok is a video. "No account required" is not a video.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Ask "what's the screenshot?" before writing code.&lt;/strong&gt; Not "what's the feature list" — what is the concrete artifact a user would send to another person? If you can't answer that, you don't have a distribution plan, and no amount of ASO tuning will retrofit one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correct ≠ compelling.&lt;/strong&gt; I optimized hard for being right about privacy and never checked whether being right was interesting. Those are independent variables and I assumed the first implied the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check keyword ranks on day one, not week three.&lt;/strong&gt; Two lines of Python against a public API. I built for weeks on the assumption that shipping put me in the game. It didn't — I was never in the search results at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at what the newcomers did, not what the leaders did.&lt;/strong&gt; The category leaders won years ago under different conditions and their playbook doesn't transfer. Apps that broke in &lt;em&gt;within the last 12 months&lt;/em&gt; faced the same App Store you're facing right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I am now
&lt;/h2&gt;

&lt;p&gt;Still 1 review. Since drafting this I've kept the rank check running daily: that one keyword went #21 → #26 → #22 and is now #24. Ten days of content and link work moved it exactly nowhere, which is a fairly loud confirmation of the diagnosis above rather than a refutation of it.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend this has a happy ending yet — the fix is adding a mechanic worth telling someone about, and that's a product change, not a marketing one.&lt;/p&gt;

&lt;p&gt;Two things you can take without installing anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rank check, packaged properly:&lt;/strong&gt; &lt;a href="https://github.com/shantj/asorank" rel="noopener noreferrer"&gt;&lt;code&gt;asorank&lt;/code&gt;&lt;/a&gt; — single file, stdlib only, no API key and no account. &lt;code&gt;python3 asorank.py --id &amp;lt;appid&amp;gt; --terms "app blocker,screen time"&lt;/code&gt; prints a table. MIT, 31 tests. It exists because three things bit me that the ten-line version above doesn't handle: Apple serves &lt;strong&gt;HTTP 200 for App Store URLs that don't exist&lt;/strong&gt; (so status-code link validation silently passes dead links), apps aren't on every storefront, and rapid queries get 403'd.&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://shantj.github.io/sproutguard/screen-time-calculator.html" rel="noopener noreferrer"&gt;screen time calculator&lt;/a&gt; I built while trying to find a "screenshot" for this idea. It converts hours/day into &lt;strong&gt;waking&lt;/strong&gt; years lost, not calendar years — 2h/day is 1/12 of a day, which is dismissible, but 1/8 of your conscious life, which isn't. No signup, runs in the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app itself is &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-2" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt;, free, if you want to see the "correct but boring" version for yourself.&lt;/p&gt;

&lt;p&gt;If you're building something in a crowded category: run the rank check before you ship. It takes ten minutes and it would have saved me three weeks of building the wrong thing well.&lt;/p&gt;

</description>
      <category>aso</category>
      <category>appstore</category>
      <category>ios</category>
      <category>marketing</category>
    </item>
    <item>
      <title>I built a screen time blocker that has no account, no server, and no idea who you are</title>
      <dc:creator>Sam_tj</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:06:16 +0000</pubDate>
      <link>https://dev.to/samtj/i-built-a-screen-time-blocker-that-has-no-account-no-server-and-no-idea-who-you-are-2oe0</link>
      <guid>https://dev.to/samtj/i-built-a-screen-time-blocker-that-has-no-account-no-server-and-no-idea-who-you-are-2oe0</guid>
      <description>&lt;p&gt;I was doomscrolling about three hours a day. Not "checking my phone a lot" — three hours, measured, on a screen time report I didn't want to look at.&lt;/p&gt;

&lt;p&gt;So I did what developers do: I went looking for a tool. What I found was a category with two flavors, and neither one was built for me.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-1" rel="noopener noreferrer"&gt;SproutGuard&lt;/a&gt; instead — free, no account, entirely on-device. This post is about why the existing two flavors don't work, which is the part that took the research.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flavor 1: parental control wearing a productivity costume
&lt;/h2&gt;

&lt;p&gt;Most "app blocker" apps assume a supervisor and a supervised. A parent installs it. Someone else holds the password. Someone else gets the report.&lt;/p&gt;

&lt;p&gt;The reviews tell the story better than I can. This is a real 1-star review of one of the biggest apps in the category (58k+ ratings, 4.3★):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My parents installed this for me. I don't have a porn addiction. I cannot use the internet without fear of being monitored and needing to be 'accountable'."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That model works fine for kids. But I'm an adult who already knows the problem. I don't need a warden. I need a wall — and I want to be the one who builds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flavor 2: your habits, now on someone else's server
&lt;/h2&gt;

&lt;p&gt;The other half of the category wants an account, a subscription, and a copy of your usage data. You wanted to spend less time on your phone. Now a company you've never met has a timeline of every app you opened and when.&lt;/p&gt;

&lt;p&gt;For a category that markets itself on "digital wellbeing," that's a strange trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted
&lt;/h2&gt;

&lt;p&gt;A short list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blocks the apps that eat my day&lt;/li&gt;
&lt;li&gt;Nobody gets a report on me — not a parent, not a partner, not a vendor&lt;/li&gt;
&lt;li&gt;No login&lt;/li&gt;
&lt;li&gt;Something that makes the habit stick past day three&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it sounds. Blocking alone doesn't work — you disable it in a weak moment and never turn it back on. What works is a number you don't want to reset.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technical part: you don't need a backend
&lt;/h2&gt;

&lt;p&gt;Here's the thing that surprised me. On iOS, &lt;strong&gt;you can build this entire category with no server at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apple's &lt;a href="https://developer.apple.com/documentation/screentime" rel="noopener noreferrer"&gt;Screen Time API&lt;/a&gt; (the &lt;code&gt;FamilyControls&lt;/code&gt; / &lt;code&gt;ManagedSettings&lt;/code&gt; / &lt;code&gt;DeviceActivity&lt;/code&gt; trio) does the blocking at the system level, on-device. The relevant pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FamilyControls&lt;/code&gt; — authorization, plus &lt;code&gt;FamilyActivityPicker&lt;/code&gt; for choosing apps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManagedSettings&lt;/code&gt; — applies the actual shields&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DeviceActivity&lt;/code&gt; — schedules and monitors activity windows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Critically, the app tokens you get back from the picker are &lt;strong&gt;opaque&lt;/strong&gt;. You don't receive bundle IDs. You literally &lt;em&gt;cannot&lt;/em&gt; build a profile of what the user selected even if you wanted to — Apple hands you a token that only means something to the system.&lt;/p&gt;

&lt;p&gt;That constraint is usually framed as a limitation. I think it's the best part of the API. It makes "we don't track you" structurally true rather than a promise in a privacy policy. There's no server, so there's nothing to leak, subpoena, or sell.&lt;/p&gt;

&lt;p&gt;Streak state, session history, settings — all of it lives on-device. The entire app has zero network calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SproutGuard&lt;/strong&gt; — &lt;a href="https://apps.apple.com/app/id6768664921?ct=devto-1" rel="noopener noreferrer"&gt;free on the App Store&lt;/a&gt;, or read &lt;a href="https://shantj.github.io/sproutguard/" rel="noopener noreferrer"&gt;what it does and why&lt;/a&gt; first.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Block specific apps and sites&lt;/li&gt;
&lt;li&gt;Focus sessions&lt;/li&gt;
&lt;li&gt;Daily streaks (the part that actually makes it stick)&lt;/li&gt;
&lt;li&gt;Usage trends&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No account. No cloud. No surveillance. It's for blocking &lt;em&gt;yourself&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"But you can just turn it off."&lt;/strong&gt; Yes. It's your phone. I went back and forth on adding hard-lock mechanisms and decided against every one of them — the moment an app fights its own user, it becomes the thing I was trying to avoid. Most doomscrolling isn't a decision anyway; it's a reflex. A wall interrupts the reflex long enough for a decision to happen. That's most of the value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Being right isn't the same as being interesting.&lt;/strong&gt; "On-device, no account, privacy-first" is a correct positioning and a boring one. Nobody tells a friend about your app because it has no backend. The apps growing fastest in this category have a &lt;em&gt;hook&lt;/em&gt; — one makes you do push-ups to unlock Instagram. I built the principled version and skipped the shareable one. Still working on that.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building in this space
&lt;/h2&gt;

&lt;p&gt;The Screen Time API is genuinely pleasant and under-used. If you've been assuming an app like this needs a backend — it doesn't. You can ship a full screen-time product with zero infrastructure cost, zero data liability, and a privacy story that's true by construction.&lt;/p&gt;

&lt;p&gt;One side-effect of building this: I ended up writing a small no-signup&lt;br&gt;
&lt;a href="https://shantj.github.io/sproutguard/screen-time-calculator.html" rel="noopener noreferrer"&gt;screen time calculator&lt;/a&gt;&lt;br&gt;
to sanity-check my own numbers. It converts daily hours into &lt;em&gt;waking&lt;/em&gt; years rather than&lt;br&gt;
calendar years, which is the framing that actually changed how I read my own weekly report —&lt;br&gt;
2h/day is 1/12 of a calendar day but 1/8 of your conscious life. It runs entirely in the&lt;br&gt;
browser, no account, and it's free to use or reference.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the Screen Time API in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ios</category>
      <category>privacy</category>
      <category>appstore</category>
    </item>
  </channel>
</rss>
