<?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: Sanjay Kumar Sah</title>
    <description>The latest articles on DEV Community by Sanjay Kumar Sah (@sanjaysah).</description>
    <link>https://dev.to/sanjaysah</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%2F1656931%2F92645e0c-3291-4e97-aa16-b1904ecae286.jpg</url>
      <title>DEV Community: Sanjay Kumar Sah</title>
      <link>https://dev.to/sanjaysah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sanjaysah"/>
    <language>en</language>
    <item>
      <title>Google Sign-In Works in Debug but Fails in Production on Android? Check This Hidden SHA-1</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 12 Sep 2026 09:12:30 +0000</pubDate>
      <link>https://dev.to/sanjaysah/google-sign-in-works-in-debug-but-fails-in-production-on-android-check-this-hidden-sha-1-18nn</link>
      <guid>https://dev.to/sanjaysah/google-sign-in-works-in-debug-but-fails-in-production-on-android-check-this-hidden-sha-1-18nn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚢 &lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my Shipaton entry, and I'm sharing the whole journey — the wins and the 5 AM disasters like this one. This is one chapter of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google Sign-In worked on my development build. It worked on my preview build. On the version Google Play delivered to real testers, tapping &lt;strong&gt;"Sign in with Google"&lt;/strong&gt; did absolutely nothing — no account picker, no error message, no crash. Just a button that did nothing.&lt;/p&gt;

&lt;p&gt;It took me several days to find. The answer turned out to be a fingerprint that Play Console doesn't show you anymore.&lt;/p&gt;

&lt;p&gt;If you're here from a search, the short version is below.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the fix
&lt;/h2&gt;

&lt;p&gt;If your app uses &lt;strong&gt;Play App Signing&lt;/strong&gt; and you've opted into &lt;strong&gt;Quantum-ready app signing (beta)&lt;/strong&gt;, the SHA-1 fingerprint buttons on Play Console's App signing page are &lt;strong&gt;not&lt;/strong&gt; the certificate that signs your app.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Play Console → Protect with Play → Play Store protection → Manage Play app signing&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; (not the SHA-1 fingerprint buttons)&lt;/li&gt;
&lt;li&gt;You'll get three files. The one you need is &lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Get its SHA-1 fingerprint (commands below)&lt;/li&gt;
&lt;li&gt;Put that fingerprint in your &lt;strong&gt;Android OAuth client&lt;/strong&gt; in Google Cloud Console&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No rebuild needed. Mine started working within minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Google Sign-In is broken" actually looked like
&lt;/h2&gt;

&lt;p&gt;Nothing. That's the hard part.&lt;/p&gt;

&lt;p&gt;On modern Android, Sign in with Google runs through a system component called &lt;strong&gt;Credential Manager&lt;/strong&gt;. When it can't complete a sign-in, it hands your app back a result that means "cancelled." Here's the code in the library I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GetCredentialCancellationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;OneTapResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NoCredentialException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;OneTapResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;noSavedCredential&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;The problem: &lt;strong&gt;"cancelled" is also what you get when a user swipes the dialog away.&lt;/strong&gt; Android uses one bucket for all of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user genuinely dismissed the dialog&lt;/li&gt;
&lt;li&gt;your app isn't registered properly with Google&lt;/li&gt;
&lt;li&gt;your app's security certificate doesn't match what Google expects&lt;/li&gt;
&lt;li&gt;your OAuth consent screen is still in "Testing" mode and this person isn't on the tester list&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No error code. No message. Nothing to look up. And it only happens in the version you can't easily debug — the one Google Play installed on someone else's phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plain English: what a "signing certificate" is and why it matters
&lt;/h2&gt;

&lt;p&gt;Skip this if you already know. It's the core of the bug, so it's worth being clear.&lt;/p&gt;

&lt;p&gt;Every Android app is &lt;strong&gt;digitally signed&lt;/strong&gt; — think of it as a tamper-proof wax seal stamped onto the app file. The seal proves the app came from you and hasn't been modified.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;SHA-1 fingerprint&lt;/strong&gt; is just a short, unique ID for that seal. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your app asks Google "please sign this person in," Google checks two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is this app's package name registered with me? (e.g. &lt;code&gt;com.example.myapp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the app's seal match the fingerprint I have on file?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If either answer is no, Google refuses — and on Android, that refusal comes back as "cancelled." So a wrong fingerprint looks exactly like a user changing their mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: Google Play re-seals your app
&lt;/h2&gt;

&lt;p&gt;Here's what trips up most people, and it's worth understanding even if your bug turns out to be something else.&lt;/p&gt;

&lt;p&gt;When you upload your app to Google Play, you sign it with your &lt;strong&gt;upload key&lt;/strong&gt;. Play then &lt;strong&gt;removes your seal and applies its own&lt;/strong&gt; before sending the app to users. This is a feature called Play App Signing, and it exists so that losing your own key doesn't lock you out of updating your app forever.&lt;/p&gt;

&lt;p&gt;The consequence: &lt;strong&gt;the app on a user's phone has a different fingerprint than the file you uploaded.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you need to register &lt;em&gt;both&lt;/em&gt; fingerprints with Google:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your &lt;strong&gt;upload key&lt;/strong&gt; fingerprint (for builds you install directly, e.g. from a CI service)&lt;/li&gt;
&lt;li&gt;Play's &lt;strong&gt;app signing key&lt;/strong&gt; fingerprint (for everything from the Play Store)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Register only the first, and every Play Store install fails while every local install works perfectly. That is the classic version of this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  I checked the obvious thing. It looked correct.
&lt;/h2&gt;

&lt;p&gt;So naturally, that's where I looked first.&lt;/p&gt;

&lt;p&gt;I opened Google Cloud Console. The Android OAuth client had the right package name. It had a SHA-1 fingerprint copied straight from Play Console's app signing page. The consent screen was published to production. No unusual permissions requested. Everything matched.&lt;/p&gt;

&lt;p&gt;I concluded the fingerprint theory was dead and started investigating the user's Google account on the device instead.&lt;/p&gt;

&lt;p&gt;That was wrong, and it cost me another day. My mistake was simple: &lt;strong&gt;I was comparing a field in one console against a field in another console and trusting both.&lt;/strong&gt; Neither of them described the app sitting on the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breakthrough: stop reading the console, read the phone
&lt;/h2&gt;

&lt;p&gt;The console tells you what you &lt;em&gt;configured&lt;/em&gt;. The phone tells you what's &lt;em&gt;true&lt;/em&gt;. Google checks the certificate of the app that's &lt;strong&gt;actually installed&lt;/strong&gt;, so that's the thing to measure.&lt;/p&gt;

&lt;p&gt;So I plugged in the test phone, pulled the installed app off it, and read its fingerprint directly. (Full step-by-step is further down — it's optional, and you probably won't need it.)&lt;/p&gt;

&lt;p&gt;Out came:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matched &lt;strong&gt;neither&lt;/strong&gt; the fingerprint registered in Google Cloud &lt;strong&gt;nor&lt;/strong&gt; my upload key. A third fingerprint I had never seen before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cause: Quantum-ready app signing (beta)
&lt;/h2&gt;

&lt;p&gt;Back in Play Console, there was a small badge on the app signing page I'd never paid attention to: &lt;strong&gt;Quantum-ready (beta)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Quantum-ready app signing is Google preparing for a future where today's encryption can be broken by quantum computers. If you opt in, your app gets a &lt;strong&gt;hybrid key&lt;/strong&gt; — a traditional ("classical") half and a new post-quantum half.&lt;/p&gt;

&lt;p&gt;It also quietly changes the page. Where Play Console used to show plain text rows — MD5, SHA-1, SHA-256 of your app signing certificate — it now shows two buttons instead: &lt;strong&gt;Classical key&lt;/strong&gt; and &lt;strong&gt;Post-quantum cryptography key&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neither button gives you the certificate that signs your app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; and you get three files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it actually is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The certificate Play uses to seal your app. This is the one to register.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid_classical_cert.der&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Classical half of the quantum-ready pair — what the SHA-1 button copies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid_pqc_cert.der&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Post-quantum half — also not it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deployment certificate — the ordinary Play App Signing key that has been there all along — &lt;strong&gt;has no fingerprint button on that page at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I had dutifully copied the only SHA-1 the page offered me. It was never going to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it, step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Play Console → Protect with Play → Play Store protection → Manage Play app signing&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download certificates&lt;/strong&gt; and unzip. Find &lt;code&gt;deployment_cert.der&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read its SHA-1 fingerprint (see the next section)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google Cloud Console → APIs &amp;amp; Services → Credentials&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Open your &lt;strong&gt;Android&lt;/strong&gt; OAuth client, or create one (type &lt;em&gt;Android&lt;/em&gt;, package name = your app's package)&lt;/li&gt;
&lt;li&gt;Paste the fingerprint into &lt;strong&gt;SHA-1 certificate fingerprint&lt;/strong&gt;. Save&lt;/li&gt;
&lt;li&gt;Wait a few minutes, then test on a Play Store build&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While you're there, create a &lt;strong&gt;second&lt;/strong&gt; Android OAuth client with your &lt;strong&gt;upload key&lt;/strong&gt; fingerprint, so builds you install directly also work. Each client holds exactly one fingerprint, so you need one per certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to read a .der fingerprint
&lt;/h2&gt;

&lt;p&gt;A certificate's SHA-1 fingerprint is simply a SHA-1 hash of the certificate file itself. So you don't need any Android tooling at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows PowerShell&lt;/strong&gt; — nothing to install, and it formats the result with colons exactly the way Google Cloud wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;New-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;System.Security.Cryptography.X509Certificates.X509Certificate2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"deployment_cert.der"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Thumbprint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-replace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'(..)(?!$)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'$1:'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows, even shorter&lt;/strong&gt; — &lt;code&gt;certutil&lt;/code&gt; ships with Windows (output has no colons; Google Cloud accepts it either way):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-hashfile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;deployment_cert.der&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SHA1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;macOS / Linux / anywhere with a JDK or OpenSSL:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keytool &lt;span class="nt"&gt;-printcert&lt;/span&gt; &lt;span class="nt"&gt;-file&lt;/span&gt; deployment_cert.der

&lt;span class="c"&gt;# or&lt;/span&gt;
openssl x509 &lt;span class="nt"&gt;-inform&lt;/span&gt; DER &lt;span class="nt"&gt;-in&lt;/span&gt; deployment_cert.der &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-fingerprint&lt;/span&gt; &lt;span class="nt"&gt;-sha1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A genuine Play App Signing certificate shows &lt;code&gt;CN=Android, O=Google Inc.&lt;/code&gt; as its owner. That's a good sanity check that you grabbed the right file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: prove it by reading the app on the phone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You probably don't need this section.&lt;/strong&gt; If registering &lt;code&gt;deployment_cert.der&lt;/code&gt;'s fingerprint fixed your sign-in, you're done — close the tab.&lt;/p&gt;

&lt;p&gt;Do this when the fix &lt;em&gt;didn't&lt;/em&gt; work, or when you want hard proof of which certificate is on a device before you change anything. It's the step that ended a week of guessing for me, and it works on any Android phone with any app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Get &lt;code&gt;adb&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;adb&lt;/code&gt; (Android Debug Bridge) is the tool that talks to a connected phone. &lt;strong&gt;You do not need Android Studio.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download &lt;strong&gt;SDK Platform-Tools&lt;/strong&gt; for your OS: &lt;a href="https://developer.android.com/tools/releases/platform-tools" rel="noopener noreferrer"&gt;developer.android.com/tools/releases/platform-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Unzip it anywhere — a &lt;code&gt;platform-tools&lt;/code&gt; folder with &lt;code&gt;adb&lt;/code&gt; inside&lt;/li&gt;
&lt;li&gt;Either add that folder to your &lt;code&gt;PATH&lt;/code&gt;, or just run &lt;code&gt;adb&lt;/code&gt; with its full path&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Quick check that it runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb version
&lt;span class="c"&gt;# Android Debug Bridge version 1.0.41&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS users can alternatively &lt;code&gt;brew install --cask android-platform-tools&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Turn on USB debugging on the phone
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Settings → About phone&lt;/strong&gt; → tap &lt;strong&gt;Build number&lt;/strong&gt; seven times. You'll see "You are now a developer"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings → System → Developer options&lt;/strong&gt; → turn on &lt;strong&gt;USB debugging&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Plug the phone into your computer with a USB cable&lt;/li&gt;
&lt;li&gt;Change the USB mode to &lt;strong&gt;File transfer / MTP&lt;/strong&gt; (not "Charging only") — on some phones the debugging prompt won't appear otherwise&lt;/li&gt;
&lt;li&gt;A dialog appears on the phone: &lt;strong&gt;"Allow USB debugging?"&lt;/strong&gt; → tick &lt;em&gt;Always allow from this computer&lt;/em&gt; → &lt;strong&gt;Allow&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Confirm the computer can see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb devices &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# List of devices attached&lt;/span&gt;
&lt;span class="c"&gt;# 95QW4TIFNRIBCAR8   device product:... model:...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;unauthorized&lt;/code&gt;, you missed the prompt on the phone. If the list is empty, try a different cable — plenty of USB cables are charge-only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Confirm you're testing the Play Store build
&lt;/h3&gt;

&lt;p&gt;This matters. A sideloaded build carries a different certificate, and testing the wrong one sends you down the wrong path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pm list packages &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;com.example.myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;installer=&lt;/code&gt; value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;installer=com.android.vending&lt;/code&gt; → installed by the &lt;strong&gt;Play Store&lt;/strong&gt; ✅ this is what you want&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installer=com.google.android.packageinstaller&lt;/code&gt; → sideloaded APK&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installer=null&lt;/code&gt; → installed by a developer tool&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4 — Pull the app off the phone
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell pm path com.example.myapp
&lt;span class="c"&gt;# package:/data/app/~~aBcD.../com.example.myapp-XyZ.../base.apk&lt;/span&gt;

adb pull /data/app/~~aBcD.../com.example.myapp-XyZ.../base.apk ondevice.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the exact path the first command printed. If it prints several lines, you want the one ending in &lt;code&gt;base.apk&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Read its real fingerprint
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;If you have Android SDK build-tools installed&lt;/strong&gt;, this is a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apksigner verify &lt;span class="nt"&gt;--print-certs&lt;/span&gt; ondevice.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watch out for this trap.&lt;/strong&gt; The obvious command does &lt;em&gt;not&lt;/em&gt; work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keytool &lt;span class="nt"&gt;-printcert&lt;/span&gt; &lt;span class="nt"&gt;-jarfile&lt;/span&gt; ondevice.apk
&lt;span class="c"&gt;# (prints absolutely nothing)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apps delivered by Play use a newer signature format that &lt;code&gt;-jarfile&lt;/code&gt; can't read. It doesn't fail with an error — it &lt;strong&gt;succeeds and prints nothing at all&lt;/strong&gt;. Easy to misread as "no info available." I did exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No build-tools?&lt;/strong&gt; Save this as &lt;code&gt;apk-cert.js&lt;/code&gt; and run &lt;code&gt;node apk-cert.js ondevice.apk&lt;/code&gt;. No dependencies:&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="c1"&gt;// apk-cert.js — print the signing certificate fingerprint of an APK.&lt;/span&gt;
&lt;span class="c1"&gt;// Works on modern (v2/v3-signed) APKs, including ones delivered by Google Play.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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="c1"&gt;// 1. Find the ZIP "end of central directory" record. It points at the central&lt;/span&gt;
&lt;span class="c1"&gt;//    directory, and the APK Signing Block sits immediately before that.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;=&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="k"&gt;for &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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&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;22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&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;66000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x06054b50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eocd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not a ZIP/APK file&lt;/span&gt;&lt;span class="dl"&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;cdOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eocd&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&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;cdOffset&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="nx"&gt;cdOffset&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;APK Sig Block 42&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No APK Signing Block — try: keytool -printcert -jarfile &amp;lt;apk&amp;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="c1"&gt;// 2. Walk the block's id/value pairs. 0xf05368c0 = scheme v3, 0x7109871a = v2.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBigUInt64LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&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;off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;blockSize&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&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;blockEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cdOffset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&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;blocks&lt;/span&gt; &lt;span class="o"&gt;=&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;off&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;blockEnd&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;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBigUInt64LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&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;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;len&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;schemes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mh"&gt;0xf05368c0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v3&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="mh"&gt;0x7109871a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;id&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;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&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;schemes&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No v2/v3 signature found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. signers -&amp;gt; first signer -&amp;gt; signed data -&amp;gt; certificates -&amp;gt; first certificate (DER).&lt;/span&gt;
&lt;span class="c1"&gt;//    Every field is a 4-byte little-endian length followed by that many bytes.&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schemes&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&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;p&lt;/span&gt; &lt;span class="o"&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;u32&lt;/span&gt; &lt;span class="o"&gt;=&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&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;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                  &lt;span class="c1"&gt;// length of the signers sequence&lt;/span&gt;
  &lt;span class="nf"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                  &lt;span class="c1"&gt;// length of the first signer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedDataLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;u32&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;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&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;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;signedDataLen&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;q&lt;/span&gt; &lt;span class="o"&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;s32&lt;/span&gt; &lt;span class="o"&gt;=&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32LE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&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;x&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;digestsLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;digestsLen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// skip the digests section&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;certsLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&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;certsEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;certsLen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; signature ---`&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;q&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;certsEnd&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;certLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;s32&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;der&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sd&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;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;certLen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;certLen&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;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alg&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;der&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/../g&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  certificate #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;n&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`    SHA-1  : &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha1&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`    SHA-256: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like this — and both signature versions should agree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- v3 signature ---
  SHA-1  : 88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
  SHA-256: 27:9F:A2:D6:99:52:E5:03:6F:94:26:86:00:B7:FB:81:A4:D9:4A:F8:...
--- v2 signature ---
  SHA-1  : 88:93:67:BB:FA:76:AC:40:3E:1D:AC:E2:9B:7D:2A:C0:45:7E:D1:BF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6 — Compare
&lt;/h3&gt;

&lt;p&gt;Whatever comes out of Step 5 is what Google checks. If it doesn't match the SHA-1 on your Android OAuth client, &lt;strong&gt;that is your bug&lt;/strong&gt;, no matter how correct the consoles look.&lt;/p&gt;

&lt;p&gt;One more sanity check, in case your app's signing key was ever rotated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell dumpsys package com.example.myapp | &lt;span class="nb"&gt;grep &lt;/span&gt;signatures
&lt;span class="c"&gt;# signatures:[...], past signatures:[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty &lt;code&gt;past signatures:[]&lt;/code&gt; means no key rotation is muddying the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick troubleshooting checklist
&lt;/h2&gt;

&lt;p&gt;If Google Sign-In fails only on your Play Store build, work down this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Is the &lt;strong&gt;package name&lt;/strong&gt; on your Android OAuth client exactly right? Many setups add suffixes like &lt;code&gt;.dev&lt;/code&gt; or &lt;code&gt;.staging&lt;/code&gt; per build type — each needs its own client&lt;/li&gt;
&lt;li&gt;[ ] Did you register &lt;strong&gt;Play's app signing fingerprint&lt;/strong&gt;, not just your upload key?&lt;/li&gt;
&lt;li&gt;[ ] If you use &lt;strong&gt;Quantum-ready app signing&lt;/strong&gt;, did you use &lt;code&gt;deployment_cert.der&lt;/code&gt; rather than the SHA-1 buttons?&lt;/li&gt;
&lt;li&gt;[ ] Is your &lt;strong&gt;OAuth consent screen published&lt;/strong&gt; ("In production"), not left in Testing? In Testing mode, anyone not on the tester list is blocked — and that also shows up as "cancelled"&lt;/li&gt;
&lt;li&gt;[ ] Are you passing your &lt;strong&gt;Web client ID&lt;/strong&gt; to the sign-in library, not an Android one? Most libraries want the Web client ID even on Android — it's confusing, and it's correct&lt;/li&gt;
&lt;li&gt;[ ] Still stuck? &lt;strong&gt;Pull the APK off the phone and read its real fingerprint&lt;/strong&gt; (section above)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Five things I'd tell myself a week ago
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When two dashboards agree and reality disagrees, go measure reality.&lt;/strong&gt; I spent days comparing a Google Cloud field to a Play Console field. Both were internally consistent. Neither described the app on the phone. The first &lt;code&gt;adb pull&lt;/code&gt; ended the investigation in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Beta features move where the truth lives.&lt;/strong&gt; Quantum-ready app signing didn't break anything. It relocated a value and left a similar-looking, differently-named one in its place. Every blog post and checklist that says "copy the SHA-1 from the app signing page" is now quietly wrong for these apps — including my own team's internal docs, which I've since fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that prints nothing is worse than one that errors.&lt;/strong&gt; &lt;code&gt;keytool -printcert -jarfile&lt;/code&gt; on a modern APK succeeds silently and outputs nothing. Empty output is not the same as "no data exists." Always question it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add your diagnostics before you need them.&lt;/strong&gt; I'd already wasted a build cycle adding a &lt;code&gt;console.log&lt;/code&gt;, so I made the app report on itself: every stage of sign-in sends an analytics event carrying the app version, build number, device model and configuration fingerprints. That didn't find the bug — but it eliminated my entire app as a suspect in one afternoon, and told me exactly which build and which phone each failure came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When an error collapses several causes into one label, add something that separates them.&lt;/strong&gt; "Cancelled" means four different things here. I now time how long the sign-in call takes: under about 300 milliseconds means no dialog ever appeared, which points at configuration; several seconds means the user really did see the dialog, which points at their device or account. That one number would have halved my search space on day one.&lt;/p&gt;




&lt;p&gt;If this saved you a day, the specific thing to remember is short: &lt;strong&gt;&lt;code&gt;deployment_cert.der&lt;/code&gt;, not the SHA-1 button.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>oauth</category>
      <category>reactnative</category>
      <category>debugging</category>
    </item>
    <item>
      <title>750 Free Hours a Month, but a Month Is 730: 3 Free-Tier Mistakes That Took Down My App</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Fri, 11 Sep 2026 09:58:16 +0000</pubDate>
      <link>https://dev.to/sanjaysah/750-free-hours-a-month-but-a-month-is-730-3-free-tier-mistakes-that-took-down-my-app-17g8</link>
      <guid>https://dev.to/sanjaysah/750-free-hours-a-month-but-a-month-is-730-3-free-tier-mistakes-that-took-down-my-app-17g8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🚢 &lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my Shipaton entry, and I'm sharing the whole journey — the wins and the 5 AM disasters like this one. This is one chapter of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At &lt;strong&gt;9 PM on September 10th, 2026&lt;/strong&gt;, Render emailed me to say my production services had been suspended.&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;5 AM on September 11th&lt;/strong&gt;, I published v1.2.0 to the Play Store.&lt;/p&gt;

&lt;p&gt;In between was the worst night of this project so far — and it was entirely my own doing. Three separate mistakes, each individually reasonable, stacked into an outage that took down a shared identity service, a product API, and a mail service at once.&lt;/p&gt;

&lt;p&gt;Here's all three, with the numbers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi04lmi9y8yggtzfrwc6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi04lmi9y8yggtzfrwc6h.png" alt="Render suspension email" width="800" height="687"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The 30-second version (if you only read this far)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free tiers are metered.&lt;/strong&gt; Render gives ~750 machine-hours/month across a workspace; a month is ~730 hours. Keeping one service awake 24/7 eats almost the entire budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A keep-alive cron is a trap.&lt;/strong&gt; Pinging services to stop them sleeping doesn't outsmart the platform — it just spends the free quota faster, and then &lt;em&gt;everything&lt;/em&gt; gets suspended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a serverless database, &lt;code&gt;min_connections: 0&lt;/code&gt; is mandatory.&lt;/strong&gt; One idle connection held open keeps the database awake 24/7 and can nearly double your usage at zero traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never bake a platform's URL into a mobile app.&lt;/strong&gt; Use a domain &lt;em&gt;you&lt;/em&gt; own, so you can move servers without shipping a new app build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're new to this, don't worry — I explain every term below in plain language.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Jargon, in one line each:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Free tier&lt;/strong&gt; = the no-cost plan, with usage caps. · &lt;strong&gt;Cold start / spin-down&lt;/strong&gt; = a sleeping server takes a few seconds to wake. · &lt;strong&gt;Cron job&lt;/strong&gt; = a task on a timer. · &lt;strong&gt;Connection pool&lt;/strong&gt; = a small set of reusable database connections. · &lt;strong&gt;Serverless / scale-to-zero database&lt;/strong&gt; = a database that sleeps when idle and bills by the second. · &lt;strong&gt;Compute Unit (CU)&lt;/strong&gt; = Neon's unit of database processing time. · &lt;strong&gt;Hostname / subdomain&lt;/strong&gt; = the address in a URL, like &lt;code&gt;auth.notils.com&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I'm a solo developer. The stack is deliberately cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three Rust backend services — a shared identity service, a product API, and a transactional email service&lt;/li&gt;
&lt;li&gt;Postgres on Neon's free tier&lt;/li&gt;
&lt;li&gt;A React Native + Expo mobile app, in Play Store &lt;strong&gt;closed testing with 20 testers&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Everything on Render's free tier, in one workspace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free tier everywhere. That was the point. There's no revenue yet, and 20 testers don't justify a hosting bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake #1: the keep-alive cron
&lt;/h2&gt;

&lt;p&gt;Render's free services spin down after ~15 minutes of inactivity. The next request pays a 30–60 second cold start.&lt;/p&gt;

&lt;p&gt;For 20 testers, that's a genuine problem. A landlord opens the app, waits 40 seconds, and concludes the app is broken. So I did the obvious thing: a cron job pinging every service every 5 minutes to keep it awake.&lt;/p&gt;

&lt;p&gt;It worked perfectly. That was the problem.&lt;/p&gt;

&lt;p&gt;Render's free allowance is &lt;strong&gt;750 instance-hours per month, per workspace&lt;/strong&gt; — shared across every free service in it.&lt;/p&gt;

&lt;p&gt;A calendar month is about &lt;strong&gt;730 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Read those two numbers again. The free tier is sized so that &lt;em&gt;one&lt;/em&gt; always-on service consumes the entire monthly allowance with 20 hours to spare. It is not sized for two. The spin-down isn't a defect you work around — it's the mechanism that makes the arithmetic work at all.&lt;/p&gt;

&lt;p&gt;I had four services (production and staging for two of them), all pinned awake by my own cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 services × 24 hours/day = 96 instance-hours per day
750 ÷ 96 ≈ 7.8 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just under eight days to burn a month's allowance. Mine lasted about ten, because the cron didn't catch every service on every pass. Not much longer.&lt;/p&gt;

&lt;p&gt;And when the allowance runs out, Render suspends &lt;strong&gt;every free service in the workspace&lt;/strong&gt;. Not the greediest one. All of them. Staging and production together.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; before you defeat a platform's idle timeout, check what the idle timeout is &lt;em&gt;paying for&lt;/em&gt;. If the free quota is smaller than a calendar month, sleeping is not optional — it's the business model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake #2: one connection, held forever
&lt;/h2&gt;

&lt;p&gt;Then Neon emailed me too. 100 compute-unit hours on the free plan, and I was at 80%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqparmivcyhwwab07iqjq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqparmivcyhwwab07iqjq.png" alt="Neon usage / compute-hours warning email (the " width="800" height="811"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one took longer to find, because the culprit was four lines in a config struct I'd written weeks earlier and never looked at again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;DbConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;max_connections&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;min_connections&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="c1"&gt;// ← this line&lt;/span&gt;
    &lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;acquire_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;max_lifetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1800&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;&lt;code&gt;min_connections: 1&lt;/code&gt; is a completely ordinary pool setting. It means: always keep at least one connection open, so the first request after a quiet period doesn't pay for a TCP handshake and TLS negotiation. On a traditional Postgres box, it's free. You'd have no reason to think about it.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;serverless&lt;/strong&gt; Postgres, it's a standing charge.&lt;/p&gt;

&lt;p&gt;Neon (and Supabase, and PlanetScale, and every other scale-to-zero database) suspends your compute after a few minutes of inactivity. But it cannot suspend while a client is still connected. A pool that always holds one open connection keeps the compute awake &lt;strong&gt;24 hours a day, at zero traffic&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.25 CU × 730 hours = ~182 CU-hours/month
Free tier allowance:     100 CU-hours/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly twice the free allowance, burned by a service nobody was using. And &lt;code&gt;idle_timeout: 600&lt;/code&gt; didn't save me — the pool closes an idle connection after 10 minutes and then immediately opens a new one to satisfy the minimum.&lt;/p&gt;

&lt;p&gt;The fix is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;DbConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;max_connections&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;min_connections&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="c1"&gt;// let the last connection actually close&lt;/span&gt;
    &lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&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="c1"&gt;// absorb a suspended compute resuming&lt;/span&gt;
    &lt;span class="n"&gt;acquire_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&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;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// well under Neon's ~5-min suspend timer&lt;/span&gt;
    &lt;span class="n"&gt;max_lifetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1800&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;Two things worth noting beyond &lt;code&gt;min_connections: 0&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle_timeout&lt;/code&gt; has to be well below the provider's suspend timer.&lt;/strong&gt; Closing your connection after 10 minutes when the provider suspends at 5 means you never suspend. It has to drain first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise your timeouts.&lt;/strong&gt; Once the compute &lt;em&gt;does&lt;/em&gt; suspend, the next request has to wait for it to wake up. A 5-second budget that was generous for a warm connection can be tight for a cold resume. Scale-to-zero trades a standing charge for occasional latency — you have to actually budget for the latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three of my services were generated from the same template, so all three had the identical bug on the identical line. If you run a service template, a default like this propagates silently into everything you'll ever generate from it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; connection pool defaults were written for servers that are always on. If your database bills by the second and sleeps when idle, &lt;code&gt;min_connections: 0&lt;/code&gt; isn't a tuning preference. It's a correctness requirement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake #3: the one I can't undo
&lt;/h2&gt;

&lt;p&gt;The first two mistakes cost me money I didn't spend and a night I didn't sleep. This one cost me something I can't get back.&lt;/p&gt;

&lt;p&gt;While cleaning up, I deleted the suspended Render service.&lt;/p&gt;

&lt;p&gt;Render assigns every service a unique &lt;code&gt;*.onrender.com&lt;/code&gt; address. Delete the service and that address is gone permanently — you cannot recreate it, and a new service gets a new unique name. There's no "restore".&lt;/p&gt;

&lt;p&gt;My mobile app &lt;strong&gt;v1.1.0&lt;/strong&gt; — already in the hands of closed testers, already through Play Store review — had that address compiled into the binary.&lt;/p&gt;

&lt;p&gt;v1.1.0 is now permanently dead. Not degraded. Not slow. Requests to the old hostname return:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;404&lt;/span&gt; &lt;span class="ne"&gt;Not Found&lt;/span&gt;
&lt;span class="na"&gt;x-render-routing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no configuration change, no server-side fix, and no rollback that can save it. The only repair is a new build, a new review, and every tester updating.&lt;/p&gt;

&lt;p&gt;That's why I was publishing at 5 AM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flirm3qvg8nuhbrp74tq5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flirm3qvg8nuhbrp74tq5.png" alt="RentDera changelog / release notes for v1.2.0" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual root cause wasn't the deletion
&lt;/h3&gt;

&lt;p&gt;It was that a hostname &lt;strong&gt;I didn't own&lt;/strong&gt; was baked into a binary &lt;strong&gt;I couldn't hot-fix&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A mobile app is the least forgiving consumer you will ever have. A web frontend redeploys in a minute. A backend service reads an environment variable and restarts. An app in a store is frozen the moment it ships, and unfreezing it costs a review cycle measured in hours or days.&lt;/p&gt;

&lt;p&gt;So v1.2.0 ships pointing at domains I control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌  my-service-a3f9.onrender.com     ← platform's address, platform's to revoke
✅  auth.notils.com                  ← mine, points wherever I say
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now migrating providers is a DNS change. Testers notice nothing. The app doesn't need to know where the server lives, and that was true the whole time — I just hadn't made it true in the config.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; anything compiled into a store binary must be a name you own. Not a convenience URL. Not a platform subdomain. A hostname on a domain you control, from the very first build that reaches another human being.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One detail that nearly caught me a second time: I picked hostnames one label deep — &lt;code&gt;auth.notils.com&lt;/code&gt;, not &lt;code&gt;api.auth.notils.com&lt;/code&gt;. Cloudflare's free Universal SSL covers &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;*.example.com&lt;/code&gt;, &lt;strong&gt;one level only&lt;/strong&gt;. A deeper subdomain needs Advanced Certificate Manager at $10/month per zone, which is more than the server. Free-tier constraints show up in the strangest places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it actually worked
&lt;/h2&gt;

&lt;p&gt;Two production services are now live on owned domains, both fronted by Cloudflare and proxying to a Render origin:&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;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://auth.notils.com/health
&lt;span class="go"&gt;200 0.31s

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://auth.notils.com/.well-known/jwks.json
&lt;span class="go"&gt;{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"…","x":"…","y":"…"}]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things I checked, and would check again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/health/ready&lt;/code&gt;, not just &lt;code&gt;/health&lt;/code&gt;.&lt;/strong&gt; Liveness only proves the process is up. Readiness does a database round-trip, which is what actually proves the pool change didn't break the cold-resume path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The JWKS response contains no &lt;code&gt;d&lt;/code&gt; field.&lt;/strong&gt; On an identity service, &lt;code&gt;d&lt;/code&gt; is the private key component of an EC key. A JWKS endpoint that leaks it hands over the ability to mint tokens. It's a one-line check and it should be in your deploy runbook forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response headers.&lt;/strong&gt; &lt;code&gt;CF-RAY: …-KTM&lt;/code&gt; told me Cloudflare was terminating TLS at a Kathmandu edge — in-country for my users — while &lt;code&gt;x-render-origin-server: Render&lt;/code&gt; confirmed it was reaching the right origin. Useful to know before optimizing anything about where the server itself lives.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The economics nobody writes about
&lt;/h2&gt;

&lt;p&gt;The obvious response to all this is "just pay for hosting." Let's do that math honestly.&lt;/p&gt;

&lt;p&gt;Render's paid tier is $7/month per service. I need three services in production, so &lt;strong&gt;$21/month&lt;/strong&gt;, every month, before the database or anything else.&lt;/p&gt;

&lt;p&gt;A VPS with more RAM and more cores than all three services combined is cheaper per month — but the advertised price usually requires committing to &lt;strong&gt;one to four years upfront&lt;/strong&gt;. The monthly rate is often double, and renewal rates are higher still. "Cheaper per month" and "affordable this month" are not the same sentence when you're a solo developer with no revenue.&lt;/p&gt;

&lt;p&gt;So the real options for a pre-launch solo project are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pay $21/month indefinitely for 20 testers&lt;/li&gt;
&lt;li&gt;Prepay a year or more for a VPS and become your own sysadmin&lt;/li&gt;
&lt;li&gt;Make the free tier actually work&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked option 3, but properly this time: &lt;strong&gt;a new workspace, production services only, no staging, no keep-alive cron, a pool that drains to zero, and custom domains in front of everything.&lt;/strong&gt; The spin-down is back, and testers will occasionally wait a few seconds on first open. That's an honest trade for $0, and it's reversible — the day upgrading makes sense, it's a dashboard toggle and nothing else changes, because nothing downstream knows where the service lives.&lt;/p&gt;

&lt;p&gt;The staging environments are simply gone for now. That's a real cost, not a clever saving: I test against production and I'm careful. It buys back the headroom that keeps production inside the free allowance, and it's the first thing I'll restore when there's a budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself two weeks ago
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the quota arithmetic before designing around it.&lt;/strong&gt; 750 hours/month sounds generous until you divide by a 730-hour month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never add a keep-alive ping to a metered free tier.&lt;/strong&gt; You're not outsmarting the platform, you're spending the budget faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;min_connections: 0&lt;/code&gt; on any serverless database.&lt;/strong&gt; Check this today if you're on Neon, Supabase, or PlanetScale with a pooled client. It's four lines and it may be your entire bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the hostname before the first store release&lt;/strong&gt;, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a resource on a platform that assigns unique names is irreversible.&lt;/strong&gt; Read the dialog. I didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't miss the first warning email.&lt;/strong&gt; Render and Neon both warned me before suspending. I caught the second one, at 9 PM, after the damage.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  It isn't finished
&lt;/h2&gt;

&lt;p&gt;In the interest of not writing a tidier ending than I earned: the third service — the one that sends transactional email — is still down while I work through this. Both of its addresses return 404.&lt;/p&gt;

&lt;p&gt;Its two call sites fail differently, and one of them fails &lt;em&gt;silently&lt;/em&gt;. The email-verification endpoint propagates the error, so a client gets a real 500 and can tell the user to retry. But the forgot-password endpoint deliberately swallows delivery errors — because an endpoint that behaves differently for registered and unregistered addresses is an account-enumeration oracle. Correct security design. It also means a real user can request a password reset right now, see "check your email," and wait forever.&lt;/p&gt;

&lt;p&gt;That's next. But v1.2.0 is live, the testers are unblocked, and the two root causes are fixed in all three services.&lt;/p&gt;

&lt;p&gt;I'll take it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running a free-tier stack, go check &lt;code&gt;min_connections&lt;/code&gt; right now. It'll take two minutes and it might be the most expensive line in your codebase.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part of **Building RentDera in Public&lt;/em&gt;* — my journey shipping a rent-management app for the RevenueCat Shipaton 2026. Follow the series for the next chapter (including whether the email service ever comes back). If this saved you a bad night, drop a comment with the free-tier gotcha that bit you. 🚢*&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Rust · Neon · Render · Cloudflare · React Native + Expo. Full RentDera changelog: &lt;a href="https://rentdera.com/changelog" rel="noopener noreferrer"&gt;https://rentdera.com/changelog&lt;/a&gt;. #BuildInPublic #Shipaton&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>OpenPledge: See Exactly Where Your Donation Goes (Blockchain + AI for Charity)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sun, 06 Sep 2026 20:09:41 +0000</pubDate>
      <link>https://dev.to/sanjaysah/openpledge-see-exactly-where-your-donation-goes-blockchain-ai-for-charity-2a1k</link>
      <guid>https://dev.to/sanjaysah/openpledge-see-exactly-where-your-donation-goes-blockchain-ai-for-charity-2a1k</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, in one sentence
&lt;/h2&gt;

&lt;p&gt;When you donate $5 to a small charity online, you have &lt;strong&gt;no idea what actually happens to it&lt;/strong&gt; — and you usually never hear back.&lt;/p&gt;

&lt;p&gt;Big donation platforms ask you to &lt;em&gt;trust them&lt;/em&gt;. &lt;strong&gt;OpenPledge&lt;/strong&gt; flips that around: it's a giving app where you don't have to trust anyone, because you can &lt;strong&gt;see everything&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;OpenPledge is a "glass box" for charity.&lt;/strong&gt; You pick a cause, give any amount, and in that same moment four things happen — each one making your gift more transparent and more human:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;🪙 &lt;strong&gt;Your donation is written onto the &lt;a href="https://solana.com/" rel="noopener noreferrer"&gt;Solana&lt;/a&gt; blockchain.&lt;/strong&gt; Think of it as a public receipt that no one can secretly edit or delete. Anyone in the world can look it up.&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;&lt;a href="https://ai.google.dev/" rel="noopener noreferrer"&gt;Google Gemini&lt;/a&gt; writes you a personal thank-you note.&lt;/strong&gt; Not a canned "Thanks for your donation!" — a warm, specific message about the exact cause you supported.&lt;/li&gt;
&lt;li&gt;🔊 &lt;strong&gt;&lt;a href="https://elevenlabs.io/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; reads that note out loud&lt;/strong&gt; in a natural human voice, so you get a real spoken thank-you. (If the voice service is unavailable, the app falls back to your browser's built-in voice so it &lt;em&gt;always&lt;/em&gt; speaks.)&lt;/li&gt;
&lt;li&gt;❄️ &lt;strong&gt;&lt;a href="https://www.snowflake.com/" rel="noopener noreferrer"&gt;Snowflake&lt;/a&gt; keeps the big-picture numbers&lt;/strong&gt; — total raised, gifts by cause, trends over time — and powers a public &lt;strong&gt;Impact Dashboard&lt;/strong&gt; the whole community can see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's also a &lt;strong&gt;public ledger page&lt;/strong&gt;: a plain, honest list of every donation with a link to its blockchain record. No login, no sign-up, nothing hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it working (this is real, not a mock-up)
&lt;/h2&gt;

&lt;p&gt;Here's an &lt;strong&gt;actual donation recorded on the Solana blockchain&lt;/strong&gt; during testing — click it and you'll see the live transaction on the official Solana Explorer:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://explorer.solana.com/tx/ecbZYjXooQJnhD2UkXNu9G95QAiC7V9R3EqVQrqkeBCGLHBZHYhwN1FrkhhpXzstfnYEniVwQh115PA7pRRf9SD?cluster=devnet" rel="noopener noreferrer"&gt;View the on-chain donation on Solana Explorer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That link is the whole point of the project: your generosity leaves a permanent, public, verifiable trail.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔗 &lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://open-pledge.vercel.app/" rel="noopener noreferrer"&gt;open-pledge.vercel.app&lt;/a&gt;&lt;br&gt;
💻 &lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/open-pledge" rel="noopener noreferrer"&gt;github.com/sanjaysah101/open-pledge&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  A quick look
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Home — "Give small. See everything."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs22b2yhv2x9e2s3bunvs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs22b2yhv2x9e2s3bunvs.png" alt="OpenPledge home page" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browse causes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcg53p6cscf2pzrg8k93g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcg53p6cscf2pzrg8k93g.png" alt="OpenPledge campaigns page" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give, and get an instant receipt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3zbtkzyo4t6is315i8e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3zbtkzyo4t6is315i8e.png" alt="OpenPledge campaign detail and donation form" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof on the blockchain&lt;/strong&gt; — the same donation, live on Solana Explorer&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fseeg3anz6egikaeznwc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fseeg3anz6egikaeznwc8.png" alt="The donation confirmed on Solana Explorer" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The three pages
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give:&lt;/strong&gt; browse causes and donate. A receipt pops up with your AI thank-you note, a play-voice button, and your blockchain link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ledger (&lt;code&gt;/ledger&lt;/code&gt;):&lt;/strong&gt; every gift, listed openly, each linkable to Solana Explorer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact (&lt;code&gt;/impact&lt;/code&gt;):&lt;/strong&gt; live totals and charts powered by Snowflake, plus a one-line summary written by Gemini.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why "generosity" — the causes
&lt;/h2&gt;

&lt;p&gt;I built six example campaigns around the charity themes the &lt;a href="https://en.wikipedia.org/wiki/International_Day_of_Charity" rel="noopener noreferrer"&gt;International Day of Charity&lt;/a&gt; and the challenge highlight, so the demo feels real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;💧 Clean water for a school &lt;em&gt;(climate &amp;amp; poverty)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;👩‍💻 A neighborhood code lab for girls &lt;em&gt;(equity &amp;amp; inclusion)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🚲 A youth-led food-rescue bike brigade &lt;em&gt;(youth leadership)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🦾 Free 3D-printed assistive devices &lt;em&gt;(tech-driven giving)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;🌱 Replanting a wildfire-burnt ridge&lt;/li&gt;
&lt;li&gt;🧣 Winter survival kits for unhoused neighbors&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I used each sponsor technology
&lt;/h2&gt;

&lt;p&gt;I integrated &lt;strong&gt;all four&lt;/strong&gt; prize technologies, and each one does real work — none are bolted on for show:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;What it does in OpenPledge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://solana.com/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;Solana&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Records every donation on-chain (devnet) with a verifiable signature and explorer link.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;Google AI / Gemini&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Writes each donor's personal thank-you note and the impact-page summary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://elevenlabs.io/docs" rel="noopener noreferrer"&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Turns the thank-you note into natural-sounding speech (a "voice receipt").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.snowflake.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;Snowflake&lt;/strong&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Stores donations and powers the aggregate analytics on the Impact Dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A "Bring Your Own Key" mode&lt;/strong&gt; lets anyone (including the judges) paste their own API keys in the browser to test the live features — the keys stay on your device and are never stored on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;p&gt;Built with &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt;&lt;/a&gt; (React 19), &lt;a href="https://bun.sh/" rel="noopener noreferrer"&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;shadcn/ui&lt;/strong&gt;&lt;/a&gt; on &lt;a href="https://base-ui.com/" rel="noopener noreferrer"&gt;Base UI&lt;/a&gt; — scaffolded with &lt;a href="https://www.npmjs.com/package/create-notils" rel="noopener noreferrer"&gt;&lt;code&gt;create-notils&lt;/code&gt;&lt;/a&gt; and linted with &lt;a href="https://biomejs.dev/" rel="noopener noreferrer"&gt;Biome&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on honesty:&lt;/strong&gt; no real money ever moves — the blockchain entry is a symbolic, public "anchor," not custody of funds. And every integration &lt;em&gt;degrades gracefully&lt;/em&gt;: if a key or service isn't available, the app keeps working and clearly tells you which parts are live vs. simulated. That transparency felt exactly right for a project about trust.&lt;/p&gt;

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

&lt;p&gt;The biggest takeaway: &lt;strong&gt;trust in charity isn't about a bigger promise — it's about a smaller, verifiable receipt.&lt;/strong&gt; Putting a donation on a public ledger, then wrapping it in a genuinely warm AI thank-you, made a tiny $5 gift feel both &lt;em&gt;accountable&lt;/em&gt; and &lt;em&gt;human&lt;/em&gt; at the same time.&lt;/p&gt;

&lt;p&gt;Thanks to the &lt;a href="https://dev.to/devteam"&gt;DEV team&lt;/a&gt; for the theme — building in the spirit of generosity was a genuinely lovely way to spend a weekend. 💚&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Missing Environment Variable Crashed My App in Production (and I Lost My Play Store Testing Streak the Same Night)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 05 Sep 2026 17:46:32 +0000</pubDate>
      <link>https://dev.to/sanjaysah/one-missing-environment-variable-crashed-my-app-in-production-and-i-lost-my-play-store-testing-48en</link>
      <guid>https://dev.to/sanjaysah/one-missing-environment-variable-crashed-my-app-in-production-and-i-lost-my-play-store-testing-48en</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;#BuildInPublic for the &lt;a href="https://www.shipaton.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/strong&gt; RentDera is my entry, and I'm sharing the whole messy journey in public — wins, crashes, and gut-punches like the one below. This post is part of that trail.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Today I shipped &lt;strong&gt;v1.1.0&lt;/strong&gt; of RentDera, my rent-management app for landlords built with &lt;strong&gt;Expo + React Native&lt;/strong&gt;. It was a big release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edit properties, units, and tenants after creation&lt;/li&gt;
&lt;li&gt;Custom &amp;amp; recurring charges on dues&lt;/li&gt;
&lt;li&gt;Profile pictures&lt;/li&gt;
&lt;li&gt;Password reset from the login screen&lt;/li&gt;
&lt;li&gt;Email verification for new accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Google&lt;/strong&gt; (alongside email)&lt;/li&gt;
&lt;li&gt;Multi-currency support&lt;/li&gt;
&lt;li&gt;A Nepali language option&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I published at &lt;strong&gt;5 PM&lt;/strong&gt; and left to enjoy the Janmashtami festival. That's when everything went sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The app crashed on launch
&lt;/h2&gt;

&lt;p&gt;Within 30 minutes, closed testers reported the app crashing immediately. The cause was almost embarrassingly small: I forgot to add one environment variable to my production build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPO_PUBLIC_LEGAL_BASE_URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, the variable was set in my local &lt;code&gt;.env&lt;/code&gt;, so everything worked. In the published build, it was &lt;code&gt;undefined&lt;/code&gt; — and the code that read it blew up on startup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real lesson:&lt;/strong&gt; a missing env var should fail &lt;em&gt;loudly at build time&lt;/em&gt;, not silently ship and crash users. This is exactly why RentDera routes every env access through a single Zod-validated module that throws on a missing or invalid variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/env.ts (simplified)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_LEGAL_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;-- the one I forgot to provide&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Fails fast: if a var is missing, this throws immediately.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;EXPO_PUBLIC_LEGAL_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_LEGAL_BASE_URL&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;The validation was doing its job — it &lt;em&gt;did&lt;/em&gt; crash because the value was missing. The gap was in my &lt;strong&gt;CI/release pipeline&lt;/strong&gt;, where the variable was never injected. Validation catches bad values; it can't invent values you never provided to the build.&lt;/p&gt;

&lt;p&gt;I rebuilt, republished at &lt;strong&gt;6 PM&lt;/strong&gt;, and headed back out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: "Sign in with Google isn't working"
&lt;/h2&gt;

&lt;p&gt;Then a tester called: the shiny new &lt;strong&gt;Google Sign-In&lt;/strong&gt; wasn't working. I came home at &lt;strong&gt;9 PM&lt;/strong&gt; and started reading server logs. Nothing conclusive yet — Google Sign-In on native has a lot of moving parts (OAuth client IDs per platform, SHA-1 fingerprints on Android, the release keystore differing from debug). Still investigating this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that actually hurt: losing the testing streak
&lt;/h3&gt;

&lt;p&gt;To publish to the Play Store as an individual developer, Google requires &lt;strong&gt;12 testers active for 14 consecutive days&lt;/strong&gt; in closed testing. I was on &lt;strong&gt;day 5&lt;/strong&gt;. I had my 12 testers yesterday.&lt;/p&gt;

&lt;p&gt;Tonight, I refreshed the Play Console and saw one tester had &lt;strong&gt;opted out&lt;/strong&gt;. The streak resets. There's no appeal, no recovery — you start counting again.&lt;/p&gt;

&lt;p&gt;A production crash you can hotfix in an hour. Lost days you can't get back. That stung more than either bug.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzw841i346rud61e7a9dk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzw841i346rud61e7a9dk.png" alt="One Tester Opted Out" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for solo mobile devs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate env vars &lt;em&gt;and&lt;/em&gt; verify your release pipeline injects them.&lt;/strong&gt; Fail-fast validation is necessary but not sufficient — the CI/EAS build must actually supply every variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always smoke-test the production/release build&lt;/strong&gt;, not just the dev build. Native OAuth, signing, and env handling all differ between the two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-recruit Play Store testers.&lt;/strong&gt; With a 12-tester / 14-day requirement, a single opt-out can reset weeks of progress. Build a buffer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shipping is humbling — keep shipping anyway.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Off to find my 12th tester. If you'd like to help test a real-world rent-tracking app, drop a comment. 🙏&lt;/p&gt;

&lt;p&gt;I'm building RentDera in public as my &lt;strong&gt;RevenueCat Shipaton 2026&lt;/strong&gt; entry — follow along for the next chapter (including whether I ever recover that testing streak). 🚢&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full v1.1.0 changelog: &lt;a href="https://rentdera.com/changelog" rel="noopener noreferrer"&gt;https://rentdera.com/changelog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;#BuildInPublic · #Shipaton · Built with Expo + React Native.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>buildinpublic</category>
      <category>shipaton</category>
    </item>
    <item>
      <title>I Built a Database in Rust With Zero Dependencies (and What the Standard Library Quietly Gave Me)</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:11:16 +0000</pubDate>
      <link>https://dev.to/sanjaysah/i-built-a-database-in-rust-with-zero-dependencies-and-what-the-standard-library-quietly-gave-me-3n49</link>
      <guid>https://dev.to/sanjaysah/i-built-a-database-in-rust-with-zero-dependencies-and-what-the-standard-library-quietly-gave-me-3n49</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I built a small database in Rust for a 72-hour hackathon with one rule: &lt;strong&gt;no third-party packages allowed.&lt;/strong&gt; Just the language and nothing else. Here's what I had to write by hand, in plain English, and the surprisingly capable standard-library features that made it possible. Full source and a 5-minute video at the bottom.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  First, what's a "dependency" — and why does anyone care?
&lt;/h2&gt;

&lt;p&gt;When you build software today, you almost never write everything yourself. You &lt;em&gt;install&lt;/em&gt; code other people wrote. In the JavaScript world you run &lt;code&gt;npm install&lt;/code&gt;, in Python it's &lt;code&gt;pip install&lt;/code&gt;, in Rust it's adding a "crate." These installed packages are called &lt;strong&gt;dependencies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They're incredibly useful. They're also a little scary when you stop and look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A typical modern web app pulls in &lt;strong&gt;over 1,200&lt;/strong&gt; packages once you count everything. &lt;sup id="fnref1"&gt;1&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;In 2025 alone, public registries catalogued &lt;strong&gt;454,600 new &lt;em&gt;malicious&lt;/em&gt; packages&lt;/strong&gt; — pushing the running total past 1.2 million. &lt;sup id="fnref2"&gt;2&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;AI coding assistants now invent package names that don't exist — one study across 576,000 samples found &lt;strong&gt;19.7% of AI-suggested packages were hallucinated&lt;/strong&gt; — and attackers register those fake names and wait for someone to install them. &lt;sup id="fnref3"&gt;3&lt;/sup&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The famous example: back in 2016 a developer deleted an &lt;strong&gt;11-line&lt;/strong&gt; package called &lt;code&gt;left-pad&lt;/code&gt; (it just added spaces to the start of a string) and it &lt;strong&gt;broke builds across half the internet&lt;/strong&gt; — React, Babel, thousands of projects. Eleven lines. A stranger's eleven lines, sitting inside everyone's app. &lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;So a hackathon called &lt;strong&gt;&lt;a href="https://zerodepshack.com/" rel="noopener noreferrer"&gt;Zero Dependency&lt;/a&gt;&lt;/strong&gt; a hackathon run by &lt;a class="mentioned-user" href="https://dev.to/partnerships_raptors"&gt;@partnerships_raptors&lt;/a&gt; asked a simple, slightly rebellious question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;What if you built something genuinely useful using **nothing but the language itself&lt;/em&gt;&lt;em&gt;?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No packages. No &lt;code&gt;npm install&lt;/code&gt;. Just the "standard library" — the batteries that ship inside the language when you download it. I said yes. Here's what happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built: &lt;code&gt;zdb&lt;/code&gt;, a tiny database
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;&lt;code&gt;zdb&lt;/code&gt;&lt;/strong&gt;, a &lt;a href="https://en.wikipedia.org/wiki/Key%E2%80%93value_database" rel="noopener noreferrer"&gt;key-value store&lt;/a&gt;. If you've used a dictionary in Python or an object in JavaScript, you already understand it: you &lt;code&gt;put&lt;/code&gt; a value under a key, and later you &lt;code&gt;get&lt;/code&gt; it back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zdb put greeting &lt;span class="s2"&gt;"hello, world"&lt;/span&gt;
zdb get greeting
&lt;span class="c"&gt;# → hello, world&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The twist: &lt;strong&gt;it saves everything to disk and survives crashes.&lt;/strong&gt; Close the program, reopen it, your data's still there. Pull the power cord mid-write, and it recovers cleanly instead of corrupting.&lt;/p&gt;

&lt;p&gt;That's the kind of thing people normally reach for a big library like &lt;strong&gt;RocksDB&lt;/strong&gt;, &lt;strong&gt;sled&lt;/strong&gt;, or &lt;strong&gt;SQLite&lt;/strong&gt; to do. I wrote the whole engine by hand, in &lt;strong&gt;one Rust file&lt;/strong&gt;, with an &lt;strong&gt;empty dependency list&lt;/strong&gt;. Here's the proof, and it's the whole point:&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="nv"&gt;$ &lt;/span&gt;cargo tree
zdb v0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the entire "supply chain." One line: my own code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fun part: what I'd &lt;em&gt;normally&lt;/em&gt; install, and what replaced it
&lt;/h2&gt;

&lt;p&gt;This is the heart of the story. For each thing I needed, I'll show you the package I'd usually grab — and the standard-library feature I used instead. No jargon, I promise.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Turning data into bytes → I did it by hand
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;serde&lt;/code&gt; + &lt;code&gt;bincode&lt;/code&gt; (Rust's go-to tools for converting data structures into a stream of bytes you can save to a file).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; A file is just a long line of numbered boxes, each holding one byte. To save a record, I decided &lt;em&gt;exactly&lt;/em&gt; which byte goes where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ checksum ][ key length ][ value length ][ the key ][ the value ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire "format." Rust's standard library already knows how to turn a number into bytes (&lt;code&gt;to_le_bytes()&lt;/code&gt;) and back (&lt;code&gt;from_le_bytes()&lt;/code&gt;). Once I picked the layout, saving and loading was a dozen lines. No library needed — I just had to &lt;em&gt;decide&lt;/em&gt; the shape instead of letting a package decide it for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; serialization libraries are amazing when your data is complicated. When your data is simple and you control both ends, hand-writing the format is clearer &lt;em&gt;and&lt;/em&gt; faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Detecting corruption → a 15-line checksum
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;crc32fast&lt;/code&gt; (checks whether data got scrambled).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; A "checksum" is a small number calculated from your data. Save it alongside the data; if you recompute it later and it doesn't match, you know something got corrupted. The specific recipe I used — &lt;strong&gt;CRC-32&lt;/strong&gt; — is the same one inside ZIP files and your ethernet cable.&lt;/p&gt;

&lt;p&gt;It's a small loop over a lookup table. The magic moment: I tested my version against the internationally-known "correct answer" (&lt;code&gt;CRC32("123456789")&lt;/code&gt; must equal &lt;code&gt;0xCBF43926&lt;/code&gt;) — and it matched on the first try. My hand-written 15 lines produce &lt;em&gt;bit-for-bit&lt;/em&gt; the same result as the popular package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Some famous algorithms &lt;em&gt;look&lt;/em&gt; intimidating because they have scary names, but the actual code is short and testable.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stopping two programs from clobbering the same file → a "lock file"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;fs2&lt;/code&gt; (file locking).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; I needed to make sure two copies of &lt;code&gt;zdb&lt;/code&gt; don't write to the same database at once and scramble it. The trick is beautifully old-school: try to &lt;strong&gt;create&lt;/strong&gt; a file called &lt;code&gt;LOCK&lt;/code&gt;, using a mode that says &lt;em&gt;"only succeed if this file does not already exist."&lt;/em&gt; If it's already there, someone else is using the database, so I refuse to start.&lt;/p&gt;

&lt;p&gt;The standard library has exactly this: &lt;code&gt;OpenOptions::new().create_new(true)&lt;/code&gt;. One method call. When the program exits cleanly, it deletes the lock. It's the digital version of hanging a "occupied" sign on a door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; The standard library often hides a whole feature inside a single well-named option.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Reading command-line options → a simple loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;clap&lt;/code&gt; (parses &lt;code&gt;--flags&lt;/code&gt; and sub-commands for command-line tools).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; For a handful of commands (&lt;code&gt;put&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;del&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;), I just read the words the user typed and matched on them. Rust hands you those words with &lt;code&gt;std::env::args()&lt;/code&gt;. A &lt;code&gt;match&lt;/code&gt; statement does the rest. Big argument-parsing libraries are worth it for huge tools with 50 options — for 6 commands, a loop reads more clearly than a library's special syntax.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A "run this once, lazily" global → now built in
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Normally I'd install:&lt;/strong&gt; &lt;code&gt;once_cell&lt;/code&gt; or &lt;code&gt;lazy_static&lt;/code&gt; (for setting up a value the first time it's used).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually takes:&lt;/strong&gt; &lt;em&gt;Nothing new&lt;/em&gt; — Rust added this to the standard library in version 1.80 as &lt;code&gt;LazyLock&lt;/code&gt;. This is the happiest kind of "package killer": the language caught up, and a crate millions of people still install out of habit is now unnecessary. I used it to build my checksum table exactly once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing that turned out harder than the docs made it look
&lt;/h2&gt;

&lt;p&gt;Here's the honest part — hackathons reward honesty over hype.&lt;/p&gt;

&lt;p&gt;One of the bonus challenges was a &lt;strong&gt;"reproducible build"&lt;/strong&gt;: compile the program twice and get a &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; file both times. Sounds trivial, right? Same code, same result?&lt;/p&gt;

&lt;p&gt;Nope. My first two builds were &lt;em&gt;different&lt;/em&gt; — 20 bytes apart. I nearly assumed my code was non-deterministic.&lt;/p&gt;

&lt;p&gt;After comparing the two files byte by byte, the culprit was almost comedic: on Windows, the compiler stamps the &lt;strong&gt;current timestamp&lt;/strong&gt; and a &lt;strong&gt;random ID&lt;/strong&gt; into the program file's header. Nothing to do with my code — the &lt;em&gt;build process itself&lt;/em&gt; was sprinkling in randomness.&lt;/p&gt;

&lt;p&gt;The fix was a single linker flag (&lt;code&gt;/Brepro&lt;/code&gt;, which tells the toolchain "zero out the timestamp, make the ID a hash of the content instead"). &lt;sup id="fnref5"&gt;5&lt;/sup&gt; After that, both builds produced the identical hash:&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="go"&gt;build A: 6d93cd01…dc0f
build B: 6d93cd01…dc0f   ✅ identical
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; "Deterministic" is a discipline, not a default. Most of us never notice because we never check. Checking is the whole exercise.&lt;/p&gt;




&lt;h2&gt;
  
  
  So… should you delete all your dependencies?
&lt;/h2&gt;

&lt;p&gt;No! That's not the takeaway, and the hackathon organizers said so themselves. Libraries exist for good reasons. You should not hand-roll cryptography, and you should not rewrite a mature database for production on a Tuesday.&lt;/p&gt;

&lt;p&gt;The real lesson is smaller and more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Know what's underneath the packages you install.&lt;/strong&gt; A surprising number of them wrap a feature your language &lt;em&gt;already has for free.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before your next &lt;code&gt;npm install&lt;/code&gt; / &lt;code&gt;pip install&lt;/code&gt; / &lt;code&gt;cargo add&lt;/code&gt;, spend 30 seconds asking: &lt;em&gt;"Does the standard library already do this?"&lt;/em&gt; Often — especially in modern Node, Python, Go, and Rust — the answer is yes. Fewer strangers in your code. Less to audit. One less name for an attacker to hijack.&lt;/p&gt;

&lt;p&gt;That's the whole idea. And honestly? Building the "boring" layer by hand was the most fun I've had coding in months.&lt;/p&gt;




&lt;h2&gt;
  
  
  See it in action (5-minute video)
&lt;/h2&gt;

&lt;p&gt;I recorded a short walkthrough — the database working, the crash-recovery test, and the &lt;code&gt;cargo tree&lt;/code&gt; moment that proves the dependency list is empty:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/LwBd9-FGAp4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The code (MIT licensed, read it top to bottom)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/zdb" rel="noopener noreferrer"&gt;github.com/sanjaysah101/zdb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;STDLIB.md&lt;/code&gt;&lt;/strong&gt; — the full list of &lt;em&gt;12&lt;/em&gt; packages I replaced and how: &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/STDLIB.md" rel="noopener noreferrer"&gt;read it here&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DESIGN.md&lt;/code&gt;&lt;/strong&gt; — how the crash-safe engine actually works: &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/DESIGN.md" rel="noopener noreferrer"&gt;read it here&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole engine and command-line tool live in &lt;strong&gt;one readable file&lt;/strong&gt;, &lt;a href="https://github.com/sanjaysah101/zdb/blob/main/src/main.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src/main.rs&lt;/code&gt;&lt;/a&gt;. If you've ever been curious what's inside a database, it's a friendly place to start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;




&lt;p&gt;&lt;em&gt;Built for the &lt;a href="https://zerodepshack.com/" rel="noopener noreferrer"&gt;Zero Dependency Hackathon 2026&lt;/a&gt; by &lt;a href="https://raptors.dev/" rel="noopener noreferrer"&gt;Hackathon Raptors&lt;/a&gt;. All statistics above are cited in the Sources section. If this was useful, a ❤️ or a ⭐ on the repo genuinely helps. Thanks for reading!&lt;/em&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;OneUptime, &lt;em&gt;"The hidden costs of dependency bloat in software development"&lt;/em&gt; (1,200+ full-tree dependencies). &lt;a href="https://oneuptime.com/blog/post/2025-09-02-the-hidden-costs-of-dependency-bloat-in-software-development/view" rel="noopener noreferrer"&gt;https://oneuptime.com/blog/post/2025-09-02-the-hidden-costs-of-dependency-bloat-in-software-development/view&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Sonatype, &lt;em&gt;"2026 State of the Software Supply Chain"&lt;/em&gt; (454,600 new malicious packages in 2025; 1.2M cumulative). &lt;a href="https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware" rel="noopener noreferrer"&gt;https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Spracklen et al., &lt;em&gt;"Package Hallucinations"&lt;/em&gt; (USENIX Security 2025) — 19.7% of AI-suggested packages hallucinated across 576k samples; see also &lt;em&gt;"Slopsquatting"&lt;/em&gt;. &lt;a href="https://en.wikipedia.org/wiki/Slopsquatting" rel="noopener noreferrer"&gt;https://en.wikipedia.org/wiki/Slopsquatting&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;The Register, &lt;em&gt;"How one dev broke Node, Babel and thousands of projects in 11 lines of JavaScript"&lt;/em&gt; (2016). &lt;a href="https://www.theregister.com/2016/03/23/npm_left_pad_chaos/" rel="noopener noreferrer"&gt;https://www.theregister.com/2016/03/23/npm_left_pad_chaos/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;Microsoft, &lt;em&gt;link.exe &lt;code&gt;/Brepro&lt;/code&gt; (reproducible builds)&lt;/em&gt;. &lt;a href="https://learn.microsoft.com/en-us/cpp/build/reference/link-command-file" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/cpp/build/reference/link-command-file&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>rust</category>
      <category>database</category>
      <category>programming</category>
      <category>hackathonraptors</category>
    </item>
    <item>
      <title>The bug that differential fuzzing cannot find</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:44:34 +0000</pubDate>
      <link>https://dev.to/sanjaysah/the-bug-that-differential-fuzzing-cannot-find-1gi4</link>
      <guid>https://dev.to/sanjaysah/the-bug-that-differential-fuzzing-cannot-find-1gi4</guid>
      <description>&lt;p&gt;&lt;em&gt;I ported a 30-million-download-a-month cron parser from TypeScript to Go and ran 36,000 differential test cases with zero unexplained divergence. Then I found a bug that no amount of fuzzing could ever have caught — because the output wasn't actually a function of the input.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written for &lt;a href="https://coderesurrection.com/2026/" rel="noopener noreferrer"&gt;Port Mortem 2026&lt;/a&gt;, a porting hackathon run by &lt;a class="mentioned-user" href="https://dev.to/partnerships_raptors"&gt;@partnerships_raptors&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I spent a weekend porting &lt;a href="https://github.com/harrisiirak/cron-parser" rel="noopener noreferrer"&gt;&lt;code&gt;cron-parser&lt;/code&gt;&lt;/a&gt;, a TypeScript library responsible for roughly 30 million npm downloads a month, to Go.&lt;/p&gt;

&lt;p&gt;The result was 2,234 lines of Go replacing 2,823 lines of TypeScript, with zero dependencies. The original relies on Luxon for its date and timezone handling.&lt;/p&gt;

&lt;p&gt;But the port itself wasn't the interesting part.&lt;/p&gt;

&lt;p&gt;Getting a library to compile in another language is mostly mechanical. The difficult question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do you prove that two implementations behave the same when they don't share a runtime, standard library, or date/time implementation?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question led me through 36,000+ differential tests, several real bugs, some deliberately strange timezones, and eventually a dependency whose behavior depended on something I wasn't testing at all:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the current time.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  First problem: you can't just run the original tests
&lt;/h2&gt;

&lt;p&gt;The hackathon required the original test suite to pass against the port.&lt;/p&gt;

&lt;p&gt;For a TypeScript → Go port, that's not literally possible.&lt;/p&gt;

&lt;p&gt;The upstream suite contains 302 Jest tests written in TypeScript. They call &lt;code&gt;expect()&lt;/code&gt; and directly exercise the TypeScript implementation. A Go binary can't execute those tests.&lt;/p&gt;

&lt;p&gt;There was another option: have Jest call into the Go implementation through Node FFI.&lt;/p&gt;

&lt;p&gt;But FFI was explicitly prohibited.&lt;/p&gt;

&lt;p&gt;And translating the tests into Go didn't solve the problem either. At that point, I would have a new test suite rather than the exact suite the judges provided. Passing my translation wouldn't prove that the original tests still passed.&lt;/p&gt;

&lt;p&gt;So instead of translating the tests, I treated them as an &lt;strong&gt;oracle&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning the test suite into a conformance oracle
&lt;/h3&gt;

&lt;p&gt;I wrote a small recorder that reads the untouched &lt;code&gt;.test.ts&lt;/code&gt; files as text and extracts literal calls such as:&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;CronExpressionParser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;expression&amp;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="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were 130 such call sites.&lt;/p&gt;

&lt;p&gt;Each call was executed against the original TypeScript implementation, and I recorded either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the exact error string, or&lt;/li&gt;
&lt;li&gt;the next 8 and previous 8 fire times as epoch milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Go implementation then replayed those exact inputs and compared the results.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;130/130 matched.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The original test files were never modified. I verified that independently by running the suite itself — all 302 tests passed under &lt;code&gt;TZ=UTC&lt;/code&gt; — and by committing SHA-256 hashes of all 24 upstream files.&lt;/p&gt;

&lt;p&gt;That gave me a clean baseline.&lt;/p&gt;

&lt;p&gt;And then the oracle immediately found three bugs my fuzzer hadn't found.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug my fuzzer could never see
&lt;/h2&gt;

&lt;p&gt;The biggest one was the default timezone.&lt;/p&gt;

&lt;p&gt;When no &lt;code&gt;tz&lt;/code&gt; option is provided, the TypeScript implementation ultimately uses the &lt;strong&gt;process timezone&lt;/strong&gt;, because Luxon falls back to the system zone.&lt;/p&gt;

&lt;p&gt;I had defaulted the Go implementation to UTC.&lt;/p&gt;

&lt;p&gt;On the machine I was developing on, that meant a difference of exactly &lt;strong&gt;5 hours and 30 minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;91 of the 130 recorded call sites were silently wrong.&lt;/p&gt;

&lt;p&gt;My differential fuzzer hadn't found it.&lt;/p&gt;

&lt;p&gt;And this wasn't because I got unlucky.&lt;/p&gt;

&lt;p&gt;It was structurally impossible for that particular fuzzer to find the bug.&lt;/p&gt;

&lt;p&gt;Every generated test explicitly supplied a timezone.&lt;/p&gt;

&lt;p&gt;So the fuzzer was exploring thousands of timezone combinations while completely bypassing the behavior that mattered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #1: the original test suite knows things your generated corpus doesn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A generated test can only explore the assumptions encoded into its generator. If the generator always supplies a timezone, it can never discover what happens when the timezone is omitted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That realization changed how I approached the rest of the port.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then DST got weird
&lt;/h2&gt;

&lt;p&gt;With the obvious bugs fixed, I built a proper differential fuzzer.&lt;/p&gt;

&lt;p&gt;The basic idea was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a random cron expression and date/time configuration.&lt;/li&gt;
&lt;li&gt;Run it against TypeScript.&lt;/li&gt;
&lt;li&gt;Run it against Go.&lt;/li&gt;
&lt;li&gt;Compare absolute epoch milliseconds.&lt;/li&gt;
&lt;li&gt;Repeat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I used 16 timezones specifically chosen to exercise different kinds of DST transitions.&lt;/p&gt;

&lt;p&gt;Not every DST transition is one hour.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/New_York&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Antarctica/Troll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;120 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Australia/Lord_Howe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Pacific/Chatham&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes, with a &lt;code&gt;+12:45&lt;/code&gt; base offset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/Santiago&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes, occurring at midnight&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These cases exposed six separate places where Go's &lt;code&gt;time&lt;/code&gt; package and Luxon behaved differently.&lt;/p&gt;

&lt;p&gt;None of these differences were obvious from reading the TypeScript code.&lt;/p&gt;

&lt;p&gt;They existed at the boundary between two languages' interpretations of time.&lt;/p&gt;

&lt;p&gt;Three cases were particularly nasty.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ambiguous local times
&lt;/h2&gt;

&lt;p&gt;During the fall-back transition, a local time such as &lt;code&gt;01:30&lt;/code&gt; can occur twice.&lt;/p&gt;

&lt;p&gt;There are two valid instants that both look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go's &lt;code&gt;time.Date&lt;/code&gt; chooses the first occurrence.&lt;/p&gt;

&lt;p&gt;Luxon preserves the offset it already has.&lt;/p&gt;

&lt;p&gt;My iteration logic couldn't escape the repeated hour and eventually hit its loop limit.&lt;/p&gt;

&lt;p&gt;The problem wasn't simply "DST is complicated."&lt;/p&gt;

&lt;p&gt;The problem was that &lt;strong&gt;the same wall-clock timestamp can represent two different points in time&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Month overflow
&lt;/h2&gt;

&lt;p&gt;Go and Luxon also disagree about month arithmetic.&lt;/p&gt;

&lt;p&gt;In Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;January 31 + 1 month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;normalizes forward and becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;March 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Luxon clamps it to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;February 28
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference had a nasty consequence.&lt;/p&gt;

&lt;p&gt;February could effectively disappear from some iteration paths, meaning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L 2 *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;—the last day of February—&lt;/p&gt;

&lt;p&gt;could fail to match anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Midnight transitions
&lt;/h2&gt;

&lt;p&gt;Then there was &lt;code&gt;America/Santiago&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its DST transition occurs at midnight.&lt;/p&gt;

&lt;p&gt;On September 6, 2026, the local day begins at 01:00.&lt;/p&gt;

&lt;p&gt;There is no local &lt;code&gt;00:00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This seemingly innocent code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;santiago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;resolves to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-05T23:00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;previous day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now consider what happens when an iterator does something equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep 5 midnight
        ↓ +1 day
Sep 6 midnight
        ↓ timezone resolution
Sep 5 23:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting date is still September 5.&lt;/p&gt;

&lt;p&gt;The iterator asks for September 5 again.&lt;/p&gt;

&lt;p&gt;And again.&lt;/p&gt;

&lt;p&gt;And again.&lt;/p&gt;

&lt;p&gt;Eventually it hits the loop limit.&lt;/p&gt;

&lt;p&gt;These weren't hypothetical edge cases.&lt;/p&gt;

&lt;p&gt;The harness found them.&lt;/p&gt;

&lt;p&gt;That is exactly what the fuzzer was supposed to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I hit a wall
&lt;/h2&gt;

&lt;p&gt;At this point, the fuzzer was doing useful work.&lt;/p&gt;

&lt;p&gt;But it kept producing another kind of failure.&lt;/p&gt;

&lt;p&gt;After roughly 70 seconds of fuzzing, it would sometimes find a divergence of exactly one DST shift.&lt;/p&gt;

&lt;p&gt;Then the next run wouldn't.&lt;/p&gt;

&lt;p&gt;The failures were concentrated around ambiguous start times.&lt;/p&gt;

&lt;p&gt;So I tried to reverse-engineer the rule.&lt;/p&gt;

&lt;p&gt;Which of the two valid instants does Luxon choose?&lt;/p&gt;

&lt;p&gt;I collected examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Ambiguous time&lt;/th&gt;
&lt;th&gt;Luxon chooses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Antarctica/Troll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-10-25 01:15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Europe/Berlin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-10-25 02:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;America/New_York&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-11-01 01:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Earlier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Pacific/Auckland&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-04-05 02:30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Later&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Australia/Lord_Howe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2026-04-05 01:45&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Later&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I tried five different hypotheses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;choose the earliest instant&lt;/li&gt;
&lt;li&gt;choose the latest instant&lt;/li&gt;
&lt;li&gt;choose the larger offset&lt;/li&gt;
&lt;li&gt;choose the smaller offset&lt;/li&gt;
&lt;li&gt;choose the pre-transition offset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every rule worked for some zones and failed for others.&lt;/p&gt;

&lt;p&gt;Each experiment meant another code change, rebuild, and roughly two-minute fuzzing run.&lt;/p&gt;

&lt;p&gt;That is where the six hours went.&lt;/p&gt;

&lt;p&gt;Eventually, I stopped guessing and opened Luxon's source.&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer was hiding in 40 lines of code
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;luxon/src/datetime.js&lt;/code&gt; was this function:&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="c1"&gt;// find the right offset a given local time. The o input is our guess, which&lt;/span&gt;
&lt;span class="c1"&gt;// determines which offset we'll pick in ambiguous cases&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fixOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tz&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment was the clue.&lt;/p&gt;

&lt;p&gt;Luxon wasn't following a universal rule like "always choose the earlier instant."&lt;/p&gt;

&lt;p&gt;It was doing a &lt;strong&gt;guess-and-correct search&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the initial guess mattered.&lt;/p&gt;

&lt;p&gt;So where did the guess come from?&lt;/p&gt;

&lt;p&gt;Another function:&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;guessOffsetForZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zone&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;zoneOffsetTs&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;zoneOffsetTs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&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;There it was.&lt;/p&gt;

&lt;p&gt;The guess was based on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;the zone's offset right now.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The output depends on the clock
&lt;/h2&gt;

&lt;p&gt;This took me a while to accept.&lt;/p&gt;

&lt;p&gt;For an ambiguous local timestamp — the repeated hour during a fall-back transition — the actual instant returned by &lt;code&gt;parse()&lt;/code&gt; can depend on &lt;strong&gt;when the function is called&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Run the same parse in January and then in July, in a timezone that observes DST, and the ambiguous timestamp can resolve differently.&lt;/p&gt;

&lt;p&gt;Same input.&lt;/p&gt;

&lt;p&gt;Same code.&lt;/p&gt;

&lt;p&gt;Different output.&lt;/p&gt;

&lt;p&gt;The difference is the wall clock.&lt;/p&gt;

&lt;p&gt;There was no hidden deterministic rule waiting to be reverse-engineered.&lt;/p&gt;

&lt;p&gt;My five failed hypotheses weren't simply bad guesses.&lt;/p&gt;

&lt;p&gt;They were attempts to fit a deterministic function to behavior that wasn't actually a function of the input alone.&lt;/p&gt;

&lt;p&gt;Conceptually, I had been trying to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output = f(input)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the real behavior was closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output = f(input, current_time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my differential fuzzer only controlled the first variable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The faithful fix
&lt;/h2&gt;

&lt;p&gt;At that point, the right solution was no longer to infer Luxon's behavior.&lt;/p&gt;

&lt;p&gt;It was to reproduce it.&lt;/p&gt;

&lt;p&gt;I transcribed &lt;code&gt;fixOffset&lt;/code&gt; into Go, including the &lt;code&gt;Math.min&lt;/code&gt; / &lt;code&gt;Math.max&lt;/code&gt; branch for invalid local times, and seeded the calculation from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the Go port now inherits the same time-dependent behavior for ambiguous start times.&lt;/p&gt;

&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;p&gt;I'm porting the library, not redesigning it.&lt;/p&gt;

&lt;p&gt;If the original implementation behaves strangely, a faithful port needs to preserve that behavior unless the goal is explicitly to create a better implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What differential fuzzing can — and cannot — prove
&lt;/h2&gt;

&lt;p&gt;By the end, my harness had run:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;36,040 cases across 106 rounds and 180 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's strong evidence of equivalence.&lt;/p&gt;

&lt;p&gt;But it could never have discovered the time-dependent behavior described above.&lt;/p&gt;

&lt;p&gt;Not with 36,000 cases.&lt;/p&gt;

&lt;p&gt;Not with 36 million.&lt;/p&gt;

&lt;p&gt;Not with unlimited compute.&lt;/p&gt;

&lt;p&gt;Because differential fuzzing fundamentally asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f(input) == g(input)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this behavior wasn't determined solely by &lt;code&gt;input&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It depended on the wall clock.&lt;/p&gt;

&lt;p&gt;That's the important limitation.&lt;/p&gt;

&lt;p&gt;Randomized differential testing is still the best tool I know for this kind of port.&lt;/p&gt;

&lt;p&gt;It found six real bugs in my implementation — several serious enough that I would have confidently shipped them.&lt;/p&gt;

&lt;p&gt;But differential fuzzing has a shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;It can only discover divergences that are reproducible from the inputs it controls.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ambient state is invisible unless you deliberately model it.&lt;/p&gt;

&lt;p&gt;That includes things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current time&lt;/li&gt;
&lt;li&gt;process timezone&lt;/li&gt;
&lt;li&gt;locale&lt;/li&gt;
&lt;li&gt;environment variables&lt;/li&gt;
&lt;li&gt;operating-system behavior&lt;/li&gt;
&lt;li&gt;cached state&lt;/li&gt;
&lt;li&gt;values initialized once at process startup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fuzzer told me that &lt;em&gt;something&lt;/em&gt; was wrong.&lt;/p&gt;

&lt;p&gt;It couldn't tell me why.&lt;/p&gt;

&lt;p&gt;And throwing more compute at it wouldn't have changed that.&lt;/p&gt;

&lt;p&gt;Reading about 40 lines of dependency source code did.&lt;/p&gt;




&lt;h2&gt;
  
  
  So is the port equivalent?
&lt;/h2&gt;

&lt;p&gt;I wouldn't claim that.&lt;/p&gt;

&lt;p&gt;My honest claim is narrower:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Go port matches 6,400 generated cases across 10 fixed seeds, all 130 recorded call sites from the original suite, and a 180-second continuous differential run — with one known residual edge case whose scope and reproduction are committed.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a weaker statement.&lt;/p&gt;

&lt;p&gt;It's also a more truthful one.&lt;/p&gt;

&lt;p&gt;For a compatibility project, "I tested a lot" is not the same thing as "I proved equivalence."&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision I'd change
&lt;/h2&gt;

&lt;p&gt;If I did the project again, I would change one major thing:&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the oracle first.
&lt;/h2&gt;

&lt;p&gt;I initially created 15 hand-picked regression cases based on what I saw while reading the DST implementation.&lt;/p&gt;

&lt;p&gt;Those cases were useful.&lt;/p&gt;

&lt;p&gt;They found three divergences in only 15 tests.&lt;/p&gt;

&lt;p&gt;But they had a fundamental weakness:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;they inherited my assumptions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My generated corpus inherited those assumptions too.&lt;/p&gt;

&lt;p&gt;Both always supplied an explicit &lt;code&gt;tz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's why the biggest correctness bug — the default timezone behavior — survived all 36,040 fuzz cases.&lt;/p&gt;

&lt;p&gt;91 call sites were wrong by 5 hours and 30 minutes.&lt;/p&gt;

&lt;p&gt;The conformance oracle caught it almost immediately.&lt;/p&gt;

&lt;p&gt;And I had initially treated that oracle as little more than bookkeeping.&lt;/p&gt;

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

&lt;p&gt;The original test suite is a corpus created by someone else, against assumptions I don't share.&lt;/p&gt;

&lt;p&gt;That's precisely what makes it valuable.&lt;/p&gt;

&lt;p&gt;Generated tests explore the space &lt;strong&gt;I thought of&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Recorded tests explore the space &lt;strong&gt;the library's authors thought of&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The second is often where your blind spots live.&lt;/p&gt;

&lt;p&gt;So next time, my order would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original suite
      ↓
Conformance oracle
      ↓
Differential fuzzing
      ↓
Targeted regression cases
      ↓
Source-level investigation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not because fuzzing is less useful.&lt;/p&gt;

&lt;p&gt;Because each technique covers a different failure mode.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two things I'd rather admit than hide
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One residual divergence
&lt;/h3&gt;

&lt;p&gt;There is one remaining divergence that occurs roughly &lt;strong&gt;1 in 30,000 cases&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It involves backward iteration from an ambiguous instant in a timezone with a sub-hour DST transition.&lt;/p&gt;

&lt;p&gt;I traced it to Luxon's &lt;code&gt;endOf(unit)&lt;/code&gt; implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;startOf(unit)
    + 1 unit
    - 1ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That combines wall-clock truncation with absolute-time arithmetic.&lt;/p&gt;

&lt;p&gt;During a 30-minute fall-back transition, the result can land an hour earlier than expected.&lt;/p&gt;

&lt;p&gt;I attempted a faithful transcription.&lt;/p&gt;

&lt;p&gt;It caused iteration to stall.&lt;/p&gt;

&lt;p&gt;The remaining alternatives were either blind approximations or porting enough of Luxon's &lt;code&gt;Duration&lt;/code&gt; and &lt;code&gt;objToTS&lt;/code&gt; machinery to effectively create a second library.&lt;/p&gt;

&lt;p&gt;I chose not to do either.&lt;/p&gt;

&lt;p&gt;The divergence is documented at the relevant function and in the findings, including its observed rate.&lt;/p&gt;

&lt;p&gt;A known, reproducible limitation is something judges can evaluate.&lt;/p&gt;

&lt;p&gt;A hidden one isn't.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two claims I got wrong
&lt;/h3&gt;

&lt;p&gt;I also made mistakes during the investigation.&lt;/p&gt;

&lt;p&gt;At one point, I claimed that Go's most popular cron library uses AND semantics for day-of-month/day-of-week, while POSIX specifies OR.&lt;/p&gt;

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

&lt;p&gt;I had sourced the claim from an issue tracker for a different library in a different language and never verified it.&lt;/p&gt;

&lt;p&gt;It's OR.&lt;/p&gt;

&lt;p&gt;I also gave two different incorrect explanations for one of the DST mechanisms before a proper sweep settled what was actually happening.&lt;/p&gt;

&lt;p&gt;Those mistakes remain in the write-up.&lt;/p&gt;

&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;p&gt;The entire point of this exercise is that running the code and checking the evidence should be allowed to prove you wrong.&lt;/p&gt;

&lt;p&gt;Removing the incorrect claims would remove part of the evidence of that process.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the port revealed about the original library
&lt;/h2&gt;

&lt;p&gt;The port was the instrument.&lt;/p&gt;

&lt;p&gt;The original library was where some of the more interesting problems surfaced.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. An existing DST bug is more complicated than it looks
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CronDate.ts&lt;/code&gt; detects DST changes by subtracting hour numbers — effectively doing wall-clock arithmetic.&lt;/p&gt;

&lt;p&gt;That breaks for transitions that aren't exactly one hour.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Antarctica/Troll&lt;/code&gt;, the transition is two hours, and the observed difference can become 3, overshooting the constant being checked.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Australia/Lord_Howe&lt;/code&gt;, the transition is only 30 minutes.&lt;/p&gt;

&lt;p&gt;Depending on which minute the iterator enters the hour, the calculated difference can be either 1 or 2.&lt;/p&gt;

&lt;p&gt;So there isn't a single hour-based constant that can correctly represent the transition.&lt;/p&gt;

&lt;p&gt;The consequence is serious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a daily job can silently skip an entire day.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The proposed fix doesn't handle 30-minute transitions
&lt;/h3&gt;

&lt;p&gt;There is an open PR proposing:&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works for hour-based transitions.&lt;/p&gt;

&lt;p&gt;But for Lord Howe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;floor(30 / 60) = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real 30-minute DST transition therefore receives zero compensation.&lt;/p&gt;

&lt;p&gt;The deeper issue is that the fields being written to are expressed in &lt;strong&gt;hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This isn't a constant problem.&lt;/p&gt;

&lt;p&gt;The unit itself needs to become minutes.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. &lt;code&gt;prev()&lt;/code&gt; can hang on a valid expression
&lt;/h3&gt;

&lt;p&gt;I also found a previously unreported case where &lt;code&gt;prev()&lt;/code&gt; can exhaust its loop limit.&lt;/p&gt;

&lt;p&gt;For example:&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;CronExpressionParser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 0 0 * * *&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="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pacific/Chatham&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currentDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-09-27T05:45:00&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="nf"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Error: Invalid expression, loop limit exceeded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression is completely valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 0 * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;every day at midnight.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But iterating backward across the Chatham spring-forward gap eventually exhausts the 10,000-step limit.&lt;/p&gt;

&lt;p&gt;The same behavior reproduces in &lt;code&gt;Australia/Lord_Howe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My port deliberately does &lt;strong&gt;not&lt;/strong&gt; reproduce this hang.&lt;/p&gt;

&lt;p&gt;Compatibility doesn't mean inheriting an infinite loop when the behavior is clearly pathological.&lt;/p&gt;

&lt;p&gt;Instead, the case is recorded in a known-divergences file with a deliberately narrow predicate so it cannot silently hide a regression in the port.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more undocumented disagreement
&lt;/h2&gt;

&lt;p&gt;There is another interesting difference between cron implementations.&lt;/p&gt;

&lt;p&gt;During the fall-back transition in &lt;code&gt;America/New_York&lt;/code&gt;, consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 1 * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A daily job at 1:30.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;cron-parser&lt;/code&gt;, it fires &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Under Go's most popular cron library, it fires &lt;strong&gt;twice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For wildcard hours, both implementations fire twice.&lt;/p&gt;

&lt;p&gt;So the behavior differs depending on whether the hour field is literal or wildcard.&lt;/p&gt;

&lt;p&gt;Neither library clearly documents this rule.&lt;/p&gt;

&lt;p&gt;That means running the same schedule through both implementations can produce a real production difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;one of your nightly jobs runs twice a year.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The boring win
&lt;/h2&gt;

&lt;p&gt;And yes, the Go port is faster.&lt;/p&gt;

&lt;p&gt;On my benchmark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Parse() + 10 × Next()

Mean:  1330 µs → 37.6 µs
p99:   ~20× faster
Memory: ~5× lower
Dependencies: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly &lt;strong&gt;35× faster on the mean&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's a nice result.&lt;/p&gt;

&lt;p&gt;It's also the least interesting part of the project.&lt;/p&gt;

&lt;p&gt;Rewriting a JavaScript library in Go and discovering that Go is faster isn't particularly surprising.&lt;/p&gt;

&lt;p&gt;The difficult work was reproducing the semantics of two completely different approaches to time.&lt;/p&gt;




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

&lt;p&gt;I started this project thinking the hard part would be porting TypeScript to Go.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The hard part was discovering what the original implementation &lt;strong&gt;actually means&lt;/strong&gt; by the same operations.&lt;/p&gt;

&lt;p&gt;"Add a month."&lt;/p&gt;

&lt;p&gt;"Start at midnight."&lt;/p&gt;

&lt;p&gt;"Choose the timezone."&lt;/p&gt;

&lt;p&gt;"Parse an ambiguous timestamp."&lt;/p&gt;

&lt;p&gt;"Find the previous occurrence."&lt;/p&gt;

&lt;p&gt;Those sound deterministic.&lt;/p&gt;

&lt;p&gt;They aren't always.&lt;/p&gt;

&lt;p&gt;They can depend on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;language semantics&lt;/li&gt;
&lt;li&gt;standard-library behavior&lt;/li&gt;
&lt;li&gt;timezone databases&lt;/li&gt;
&lt;li&gt;DST transition width&lt;/li&gt;
&lt;li&gt;wall-clock arithmetic&lt;/li&gt;
&lt;li&gt;environment configuration&lt;/li&gt;
&lt;li&gt;process timezone&lt;/li&gt;
&lt;li&gt;and, surprisingly, the current moment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why no single testing strategy was enough.&lt;/p&gt;

&lt;p&gt;The original tests exposed assumptions my generator didn't know existed.&lt;/p&gt;

&lt;p&gt;Differential fuzzing found edge cases I hadn't imagined.&lt;/p&gt;

&lt;p&gt;Source inspection explained behavior that neither could fully explain.&lt;/p&gt;

&lt;p&gt;And the most important discovery was the one I couldn't have found by generating more inputs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sometimes the bug isn't hiding in the input space.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sometimes the output depends on state you forgot was part of the program.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the bug differential fuzzing could never find.&lt;/p&gt;




&lt;h3&gt;
  
  
  Reproduce it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/sanjaysah101/port-mortem-cron-parser" rel="noopener noreferrer"&gt;https://github.com/sanjaysah101/port-mortem-cron-parser&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://youtu.be/4O2HAY4QAoM" rel="noopener noreferrer"&gt;https://youtu.be/4O2HAY4QAoM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in this write-up is reproducible with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node run.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;clones the pinned upstream repository,&lt;/li&gt;
&lt;li&gt;runs the untouched upstream test suite,&lt;/li&gt;
&lt;li&gt;replays the conformance oracle,&lt;/li&gt;
&lt;li&gt;and runs the differential fuzzer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built for &lt;a href="https://coderesurrection.com/2026/" rel="noopener noreferrer"&gt;Port Mortem 2026&lt;/a&gt; by &lt;a href="https://dev.to/partnerships_raptors"&gt;&lt;strong&gt;Hackathon Raptors&lt;/strong&gt;&lt;/a&gt;. Track C, TypeScript → Go.&lt;/p&gt;

</description>
      <category>go</category>
      <category>typescript</category>
      <category>testing</category>
      <category>hackathonraptors</category>
    </item>
    <item>
      <title>You Should Own Your UI Components, Not Just Install Them</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Wed, 29 Jul 2026 05:00:03 +0000</pubDate>
      <link>https://dev.to/sanjaysah/you-should-own-your-ui-components-not-just-install-them-bce</link>
      <guid>https://dev.to/sanjaysah/you-should-own-your-ui-components-not-just-install-them-bce</guid>
      <description>&lt;p&gt;One of my favorite ideas behind &lt;code&gt;shadcn/ui&lt;/code&gt; has nothing to do with components. It's about ownership. Unlike most UI libraries, &lt;code&gt;shadcn/ui&lt;/code&gt; doesn't ask you to install a package and hope the maintainers keep it stable forever. Instead, it generates the source code directly into your project.&lt;/p&gt;

&lt;p&gt;Those components become yours. You can edit them. Refactor them. Delete them. There is no vendor lock-in. &lt;/p&gt;

&lt;p&gt;When I started building &lt;strong&gt;&lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;create-notils&lt;/a&gt;&lt;/strong&gt;, I realized this philosophy shouldn't stop at components. It should extend to the entire project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Dependencies vs. Owning Your Code
&lt;/h2&gt;

&lt;p&gt;Most starter kits work 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;npm &lt;span class="nb"&gt;install &lt;/span&gt;some-ui-library

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application now depends on another package. Every update means waiting for a new release, and every customization risks diverging from upstream. Sometimes you end up wrapping library components just to make them fit your project. Slowly, your application becomes a thin layer around someone else's code.&lt;/p&gt;

&lt;p&gt;There's nothing inherently wrong with this approach, but I wanted something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source Code Is the API
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;create-notils&lt;/code&gt;, the generated project belongs to you. The UI components are copied into your repository. The configuration files are yours. The project structure is yours. Nothing is hidden behind an SDK.&lt;/p&gt;

&lt;p&gt;That means changing a button isn't a breaking change—it's just another Git commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Design System, Multiple Applications
&lt;/h2&gt;

&lt;p&gt;One challenge with monorepos is avoiding duplicated UI code. Instead of installing &lt;code&gt;shadcn/ui&lt;/code&gt; inside every individual application, &lt;code&gt;create-notils&lt;/code&gt; keeps a single, shared design system at the workspace root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/
└── ui/
    ├── components/
    ├── lib/
    └── styles/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every application imports components from the exact same package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notils/ui/components/ui/button&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;When a new component is added, every application immediately has access to it. There is one source of truth, not three slightly different copies floating around your workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating Isn't a Package Upgrade
&lt;/h2&gt;

&lt;p&gt;One thing I always found awkward about UI libraries is updating them. Usually, it looks 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;npm update some-library

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you read the changelog, hope nothing broke, and fix any breaking changes.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;shadcn/ui&lt;/code&gt;, updating is different because you're updating raw source files. In &lt;code&gt;create-notils&lt;/code&gt;, the workflow is intentionally simple. Want to add a component?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:add dialog

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need to see what changed upstream before you pull it in?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:diff button

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ready to update?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun ui:update button

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change is visible as a normal Git diff. No hidden abstractions. No magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Philosophy Beyond Components
&lt;/h2&gt;

&lt;p&gt;This release made me realize something: the idea of ownership applies to much more than just UI.&lt;/p&gt;

&lt;p&gt;Authentication should live in your repository. Configuration should live in your repository. Database schemas should live in your repository. Generated code should be fully understandable without having to read a framework's internal source code.&lt;/p&gt;

&lt;p&gt;That's the direction I want &lt;code&gt;create-notils&lt;/code&gt; to move toward. I don't want to build a platform that owns your application. I want to build a platform that helps &lt;em&gt;you&lt;/em&gt; own it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 0.2.0 is Live
&lt;/h2&gt;

&lt;p&gt;This release introduces the foundation for that philosophy. Highlights include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive project creation:&lt;/strong&gt; Scaffold exactly what you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo and standalone generation:&lt;/strong&gt; Two outputs from one canonical source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared &lt;code&gt;@notils/ui&lt;/code&gt; package:&lt;/strong&gt; A unified design system for your workspace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in UI commands:&lt;/strong&gt; Add, preview, and update &lt;code&gt;shadcn/ui&lt;/code&gt; components effortlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated developer guide:&lt;/strong&gt; Generated specifically for your new project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better documentation &amp;amp; testing:&lt;/strong&gt; For all generated applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is still early. Authentication, database integration, and additional capabilities are coming in future releases. But the core philosophy is already taking shape: Own your code. Reduce repetitive setup. Spend your time building products instead of rebuilding infrastructure.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>shadcnui</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Opinionated by Design: Why I Chose Sensible Defaults Over Endless Configuration</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Wed, 22 Jul 2026 06:33:14 +0000</pubDate>
      <link>https://dev.to/sanjaysah/opinionated-by-design-why-i-chose-sensible-defaults-over-endless-configuration-4lnm</link>
      <guid>https://dev.to/sanjaysah/opinionated-by-design-why-i-chose-sensible-defaults-over-endless-configuration-4lnm</guid>
      <description>&lt;p&gt;When people hear about a new project scaffolding tool, one of the first questions they ask is:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Can I choose React Query or TanStack Query?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"What about pnpm instead of Bun?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I use ESLint instead of Biome?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I choose Radix instead of Base UI?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"Can I skip Tailwind?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These are reasonable questions. In fact, I asked myself the same ones while building &lt;strong&gt;&lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;create-notils&lt;/a&gt;&lt;/strong&gt;. My first instinct was to make everything configurable.&lt;/p&gt;

&lt;p&gt;The more I thought about it, the more I realized I was about to build something I didn't actually want to use.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Configuration Trap
&lt;/h2&gt;

&lt;p&gt;Most project generators start simple. Then someone requests another option. Another package manager. Another ORM. Another authentication provider. Another CSS framework. Another UI library.&lt;/p&gt;

&lt;p&gt;Eventually the CLI starts looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;? Which package manager?
❯ npm
  pnpm
  yarn
  bun

? Which CSS framework?
? Which ORM?
? Which auth library?
? Which formatter?
? Which icon library?
? Which deployment target?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels flexible. But every new option creates more combinations to support. Five choices in one prompt don't create five possible projects. They multiply with every other prompt. The complexity grows much faster than the number of features.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Built the Tool I Wanted to Use
&lt;/h2&gt;

&lt;p&gt;One thing I've learned from building side projects is this: &lt;strong&gt;the first user should always be yourself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every project I start today uses almost exactly the same stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React 19&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shadcn/ui&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Base UI&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Biome&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo&lt;/strong&gt; (when needed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wasn't switching between ten different combinations every week. I was rebuilding the same foundation over and over. So instead of asking twenty questions during scaffolding, I decided to optimize for the workflow I actually have.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-notils my-app

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few seconds later, I'm writing features instead of answering prompts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Opinionated Doesn't Mean Closed
&lt;/h2&gt;

&lt;p&gt;There's an important distinction between &lt;strong&gt;opinionated&lt;/strong&gt; and &lt;strong&gt;restrictive&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Some tools hide their implementation behind abstractions. Others generate code you aren't expected to touch. That isn't the direction I wanted.&lt;/p&gt;

&lt;p&gt;Everything &lt;code&gt;create-notils&lt;/code&gt; generates belongs to you. The UI components are source files. The configuration files are yours. The project structure is yours. If you don't like one of my decisions, you can change it immediately.&lt;/p&gt;

&lt;p&gt;There is no vendor lock-in. The CLI gives you a starting point, not a framework you have to live inside forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Defaults Should Be Battle-Tested
&lt;/h2&gt;

&lt;p&gt;Every opinion in &lt;code&gt;create-notils&lt;/code&gt; comes from building real projects.&lt;/p&gt;

&lt;p&gt;I didn't choose Bun because it was trendy; I chose it because it's the package manager and runtime I use every day. I didn't choose Biome because it's newer than ESLint; I chose it because having one fast tool for linting and formatting simplified my workflow.&lt;/p&gt;

&lt;p&gt;The same applies to Tailwind CSS v4, shadcn/ui, Base UI, and the rest of the stack. The goal isn't to pick the newest tools. The goal is to reduce decisions that I already know the answer to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration Can Come Later
&lt;/h2&gt;

&lt;p&gt;One question I get is: &lt;em&gt;"What if someone doesn't want your stack?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's a fair point. The answer is: &lt;strong&gt;not every problem needs to be solved in version 0.1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As &lt;code&gt;create-notils&lt;/code&gt; evolves, it will become more modular. Instead of exposing every decision through dozens of prompts, I'm exploring a different direction: generate a solid foundation first, then let developers add capabilities as they need them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Database&lt;/li&gt;
&lt;li&gt;Email&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Payments&lt;/li&gt;
&lt;li&gt;Monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those additions can evolve independently without turning the initial project creation into an interrogation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Optimizing for Momentum
&lt;/h2&gt;

&lt;p&gt;One of the biggest costs of starting a new project isn't writing code. It's context switching.&lt;/p&gt;

&lt;p&gt;Every prompt interrupts momentum. Every configuration choice forces another decision before you've even written your first component. I'd rather spend those first ten minutes building a homepage than deciding which formatter to use.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;create-notils&lt;/code&gt; intentionally has very few questions. It assumes a sensible set of defaults and gets out of the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Road Ahead
&lt;/h2&gt;

&lt;p&gt;Being opinionated today doesn't prevent flexibility tomorrow. In fact, I think it makes future flexibility easier.&lt;/p&gt;

&lt;p&gt;By first defining a production-ready baseline, I can later introduce optional capabilities without compromising the core experience. The foundation stays consistent. The project grows through composition instead of endless configuration.&lt;/p&gt;

&lt;p&gt;That's the direction I want to explore next. In the next article, I'll take a closer look at how &lt;code&gt;create-notils&lt;/code&gt; is organized internally, why the UI lives in its own package, and how that decision helps keep multiple applications consistent without duplicating components.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Try out the compiler architecture yourself by running &lt;code&gt;npx create-notils my-app&lt;/code&gt;, or check out the source code on &lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>nextjs</category>
      <category>opensource</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Monorepo, Two Outputs: How I Eliminated Duplicate Starter Templates</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Mon, 20 Jul 2026 09:53:00 +0000</pubDate>
      <link>https://dev.to/sanjaysah/one-monorepo-two-outputs-how-i-eliminated-duplicate-starter-templates-33ke</link>
      <guid>https://dev.to/sanjaysah/one-monorepo-two-outputs-how-i-eliminated-duplicate-starter-templates-33ke</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part 2 of the &lt;strong&gt;Building create-notils&lt;/strong&gt; series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/sanjaysah/why-i-stopped-copy-pasting-repositories-and-started-building-my-own-starter-cli-246j"&gt;previous article&lt;/a&gt;, I explained why I stopped copy-pasting repositories and started building my own project scaffolding tool. However, one major architectural problem remained: I wanted &lt;code&gt;create-notils&lt;/code&gt; to support &lt;strong&gt;both&lt;/strong&gt; of these primary project structures.&lt;/p&gt;

&lt;p&gt;A standalone Next.js application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── src/
├── public/
├── package.json
└── components.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a Turborepo monorepo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── apps/
│   └── app/
├── packages/
│   ├── ui/
│   └── config/
├── turbo.json
└── package.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, the obvious solution is to maintain two separate templates—one for standalone and one for monorepo. Problem solved, right?&lt;/p&gt;

&lt;p&gt;Except... it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Cost of Multiple Templates
&lt;/h2&gt;

&lt;p&gt;Every starter template starts out identical. Then one day, you fix a subtle bug in one template and forget to update the other. A week later, you upgrade Next.js in one repository before getting around to the second. A month later, you improve your UI package and find yourself manually copying files back and forth between folders.&lt;/p&gt;

&lt;p&gt;Eventually, the templates slowly drift apart. &lt;strong&gt;The true cost isn't creating templates; the cost is maintaining them forever.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Changes?
&lt;/h2&gt;

&lt;p&gt;When I sat down and compared the two project layouts side by side, surprisingly little was different. The actual application code, UI components, theming, and utility functions were 100% identical.&lt;/p&gt;

&lt;p&gt;The only real differences were the structural project boundaries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Monorepo&lt;/th&gt;
&lt;th&gt;Standalone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI Package&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages/ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/components/ui&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Utilities&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@notils/ui/lib/utils&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@/lib/utils&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Configuration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shared workspace package&lt;/td&gt;
&lt;td&gt;Local configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Package Manifests&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple (&lt;code&gt;package.json&lt;/code&gt; files)&lt;/td&gt;
&lt;td&gt;Single root manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workspace Tooling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Present (&lt;code&gt;turbo.json&lt;/code&gt;, workspaces)&lt;/td&gt;
&lt;td&gt;Removed entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything else was effectively the exact same code. That single observation changed the entire architecture of &lt;code&gt;create-notils&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Different Approach: The Canonical Source of Truth
&lt;/h2&gt;

&lt;p&gt;Instead of maintaining two templates, I decided to maintain only one. The monorepo became the &lt;strong&gt;canonical source of truth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Whenever a developer chooses the monorepo option, the CLI simply clones and scaffolds it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Monorepo
│
▼
Generated Monorepo

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When someone chooses a standalone project, something much more interesting happens. The CLI dynamically transforms the canonical monorepo into a flattened standalone application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Monorepo
│
▼
Flatten Transform
│
▼
Generated Standalone

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of storing two separate templates, I generate one directly from the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thinking Like a Compiler
&lt;/h2&gt;

&lt;p&gt;This shift changed how I thought about the CLI. It isn't just copying static files anymore; &lt;strong&gt;it is compiling a project&lt;/strong&gt;. The monorepo acts as the source code, and the generated starter is the compiled output.&lt;/p&gt;

&lt;p&gt;Because of this, the transformation can be cleanly broken down into deterministic, sequential steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canonical Project
│
▼
1. Resolve Workspace Packages
│
▼
2. Rewrite Import Paths
│
▼
3. Move &amp;amp; Flatten Files
│
▼
4. Merge package.json Manifests
│
▼
5. Inline Shared Configurations
│
▼
6. Output Standalone Project

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step in this pipeline has a single, isolated responsibility. That makes the entire process predictable, highly testable, and easy to evolve over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Monorepo Flattening Works
&lt;/h2&gt;

&lt;p&gt;The transformation itself is surprisingly straightforward under the hood. Workspace packages simply become internal directories inside the application structure. For example, UI components are shifted directly into the source tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/ui/src/components/ui  ──►  src/components/ui

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shared utility functions are moved alongside them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/ui/src/lib/utils.ts  ──►  src/lib/utils.ts

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, the Abstract Syntax Tree (AST) or file contents are processed to rewrite import paths. A workspace import like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Monorepo Workspace Import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notils/ui/components/ui/button&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;Is automatically rewritten to a standard Next.js path alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Standalone Local Import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&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;The exact same transformation happens for every shared workspace reference across the codebase. Zero component logic changes—only the artificial project boundaries disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architectural Benefits
&lt;/h2&gt;

&lt;p&gt;Treating the monorepo as a canonical compilation source provides several massive advantages for long-term maintenance:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. One Source of Truth
&lt;/h3&gt;

&lt;p&gt;Every bug fix, UI tweak, and architectural improvement is made exactly once. I never have to second-guess whether both project styles are synchronized.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Effortless Upgrades
&lt;/h3&gt;

&lt;p&gt;When breaking changes drop for Next.js, Tailwind CSS, or React, I only update the canonical monorepo. Every generated project shape automatically inherits the upgraded foundation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Automated CI Testing
&lt;/h3&gt;

&lt;p&gt;Because generation is 100% deterministic, I can rigorously verify both outputs in a automated Continuous Integration (CI) pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generate Monorepo ──► Build &amp;amp; Typecheck
       │
       ▼
Generate Standalone ──► Build &amp;amp; Typecheck ──► Verify Zero "@notils/*" Imports Remain

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the standalone transformation accidentally leaves behind even a single &lt;code&gt;@notils/*&lt;/code&gt; workspace import, the CI pipeline fails immediately. Subtle scaffolding bugs become instant, actionable build failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Keep a "Shared Core" Submodule?
&lt;/h2&gt;

&lt;p&gt;One common alternative I explored was keeping shared files in a common repository or submodule and wrapping two thin project templates around them. While this sounds clean in theory, it doesn't actually eliminate maintenance duplication.&lt;/p&gt;

&lt;p&gt;You still end up manually managing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two separate &lt;code&gt;package.json&lt;/code&gt; manifests&lt;/li&gt;
&lt;li&gt;Two TypeScript (&lt;code&gt;tsconfig.json&lt;/code&gt;) configurations&lt;/li&gt;
&lt;li&gt;Two distinct linting and formatting setups&lt;/li&gt;
&lt;li&gt;Two &lt;code&gt;shadcn/ui&lt;/code&gt; configuration files&lt;/li&gt;
&lt;li&gt;Two separate build and deployment pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The duplication doesn't disappear; it just gets pushed to a different layer of the codebase. By treating the monorepo as the canonical compiler source, the standalone application becomes a zero-maintenance generated artifact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking Ahead: From Templates to Targets
&lt;/h2&gt;

&lt;p&gt;Right now, &lt;code&gt;create-notils&lt;/code&gt; focuses exclusively on modern Next.js environments. But this compiler-style architecture opens up an exciting possibility: &lt;strong&gt;instead of thinking in terms of static templates, I'm starting to think in terms of compilation targets.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single canonical project can eventually compile into completely different project shapes depending on the exact requirements of the developer. Today's supported targets are simply &lt;code&gt;monorepo&lt;/code&gt; and &lt;code&gt;standalone&lt;/code&gt;. Tomorrow, they could expand to include mobile-first layouts, desktop wrappers, or alternate frameworks that share a unified design system and backend layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The most valuable lesson I learned while building &lt;code&gt;create-notils&lt;/code&gt; wasn't about Next.js app routers or Turborepo caching—it was about aggressively reducing maintenance overhead.&lt;/p&gt;

&lt;p&gt;Every duplicated template is future technical debt. Every generated template is automation working in your favor.&lt;/p&gt;

&lt;p&gt;When building developer tooling, the fundamental question stopped being:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many templates should I maintain?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many project shapes can I generate from a single source of truth?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That mindset shift fundamentally transformed how I build and maintain open-source software.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What do you think of generating standalone projects from a monorepo source of truth? Have you tackled template drift in your own tooling before? Let's discuss in the comments!&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Try out the compiler architecture yourself by running &lt;code&gt;npx create-notils my-app&lt;/code&gt;, or check out the source code on &lt;a href="https://github.com/notils/create-notils" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>nextjs</category>
      <category>monorepo</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why I Stopped Copy-Pasting Repositories and Started Building My Own Starter CLI</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Sat, 18 Jul 2026 18:51:36 +0000</pubDate>
      <link>https://dev.to/sanjaysah/why-i-stopped-copy-pasting-repositories-and-started-building-my-own-starter-cli-246j</link>
      <guid>https://dev.to/sanjaysah/why-i-stopped-copy-pasting-repositories-and-started-building-my-own-starter-cli-246j</guid>
      <description>&lt;p&gt;Every developer has a "starter project." Some keep a GitHub template. Some duplicate their previous SaaS project. Some run &lt;code&gt;create-next-app&lt;/code&gt; and spend the next two hours installing the same dependencies, configuring the same tools, and recreating the same folder structure.&lt;/p&gt;

&lt;p&gt;I was in the second group. Every new project started the same way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun create next-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then came the checklist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install Tailwind CSS.&lt;/li&gt;
&lt;li&gt;Configure Biome.&lt;/li&gt;
&lt;li&gt;Add shadcn/ui.&lt;/li&gt;
&lt;li&gt;Organize folders.&lt;/li&gt;
&lt;li&gt;Set up a UI library.&lt;/li&gt;
&lt;li&gt;Configure TypeScript.&lt;/li&gt;
&lt;li&gt;Add environment files.&lt;/li&gt;
&lt;li&gt;Set up a monorepo.&lt;/li&gt;
&lt;li&gt;Copy utility functions.&lt;/li&gt;
&lt;li&gt;Configure path aliases.&lt;/li&gt;
&lt;li&gt;Install development tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tasks were difficult. They were just repetitive. After starting enough projects, I realized something:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I wasn't building products. I was rebuilding the same foundation over and over again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Starter Kit Trap
&lt;/h2&gt;

&lt;p&gt;Like many developers, I created a "starter repository." Whenever I wanted to build something new, I'd clone it. It worked... until it didn't.&lt;/p&gt;

&lt;p&gt;Eventually I had multiple starter repositories.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One for a monorepo.&lt;/li&gt;
&lt;li&gt;One for a standalone project.&lt;/li&gt;
&lt;li&gt;One with authentication.&lt;/li&gt;
&lt;li&gt;One without authentication.&lt;/li&gt;
&lt;li&gt;One for experiments.&lt;/li&gt;
&lt;li&gt;One that was already outdated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping them synchronized became its own maintenance project. Fix a bug in one. Forget to fix it in another. Upgrade Next.js in one repository. Forget the rest.&lt;/p&gt;

&lt;p&gt;The more starters I created, the less useful they became.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Existing Starters Didn't Quite Fit
&lt;/h2&gt;

&lt;p&gt;There are already fantastic starter kits in the ecosystem. Some focus on minimalism. Others include every feature imaginable. The problem wasn't that they were bad. The problem was that they optimized for someone else's workflow.&lt;/p&gt;

&lt;p&gt;Every project I build starts with almost the same stack.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Bun/pnpm&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4&lt;/li&gt;
&lt;li&gt;shadcn/ui&lt;/li&gt;
&lt;li&gt;Biome&lt;/li&gt;
&lt;li&gt;Production-ready project structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't want to answer twenty configuration questions every time I scaffolded a project.&lt;/p&gt;

&lt;p&gt;I wanted one command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-notils my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and be ready to start building.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opinionated Doesn't Mean Inflexible
&lt;/h2&gt;

&lt;p&gt;One thing I've learned is that &lt;strong&gt;being opinionated is often a strength&lt;/strong&gt;. Frameworks like Laravel, Rails, and even Next.js provide sensible defaults. You can always change them later. The important thing is that you're productive on day one.&lt;/p&gt;

&lt;p&gt;That's the philosophy I'm following. Instead of trying to support every possible technology combination, I picked a stack that I genuinely enjoy using every day.&lt;/p&gt;

&lt;p&gt;Everything generated by the CLI lives inside your repository.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing is hidden.&lt;/li&gt;
&lt;li&gt;Nothing is hosted.&lt;/li&gt;
&lt;li&gt;Nothing prevents you from changing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The starter gives you a foundation—not a cage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building for Myself First
&lt;/h2&gt;

&lt;p&gt;A question I've been asked is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why not make everything configurable from the beginning?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because I'm the first user. The goal wasn't to build the most flexible scaffolding tool. The goal was to eliminate repetitive work from my own workflow. Once something proves useful in real projects, it can become configurable.&lt;/p&gt;

&lt;p&gt;Until then, I'd rather spend my time building products than building configuration screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Beginning of &lt;code&gt;create-notils&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;That idea eventually became &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/create-notils" rel="noopener noreferrer"&gt;create-notils&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Right now, it focuses on doing one thing well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scaffold a modern Next.js project.&lt;/li&gt;
&lt;li&gt;Support both standalone and monorepo layouts.&lt;/li&gt;
&lt;li&gt;Configure a shared shadcn/ui setup.&lt;/li&gt;
&lt;li&gt;Use Bun, Biome, Tailwind CSS v4, and TypeScript out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's intentionally small.&lt;/p&gt;

&lt;p&gt;Future releases will add authentication, database integration, API clients, Docker support, and more—but only after those features have been battle-tested in my own projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;One design decision turned out to be far more interesting than I expected. I wanted to support both standalone projects and monorepos. Most tools solve this by maintaining two different templates.&lt;/p&gt;

&lt;p&gt;I chose a different approach: The monorepo is the single source of truth, and the standalone project is generated from it through a deterministic transform.&lt;/p&gt;

&lt;p&gt;That means I only maintain one project while generating two different project shapes.&lt;/p&gt;

&lt;p&gt;In the next article, I'll dive into how that architecture works, why I chose it, and how it avoids template duplication.&lt;/p&gt;




&lt;p&gt;I'd love to hear how you start new projects.&lt;/p&gt;

&lt;p&gt;Do you use GitHub templates, starter repositories, scaffolding tools, or something completely different?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building React Native Shouldn't Feel Like Assembling IKEA Furniture: A Modern Monorepo Starter Kit</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Tue, 14 Jul 2026 10:54:50 +0000</pubDate>
      <link>https://dev.to/sanjaysah/building-react-native-shouldnt-feel-like-assembling-ikea-furniture-a-modern-monorepo-starter-kit-pef</link>
      <guid>https://dev.to/sanjaysah/building-react-native-shouldnt-feel-like-assembling-ikea-furniture-a-modern-monorepo-starter-kit-pef</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I just want to build my app."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence sounds simple. But if you've ever started a new React Native project, you know that is rarely what actually happens.&lt;/p&gt;




&lt;p&gt;You open your terminal with excitement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five minutes later, you're already searching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"How to set up Tailwind CSS in React Native?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Should I use NativeWind?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"How do I share components between mobile and web apps?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Expo Router or React Navigation?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"How do people organize React Native monorepos?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Should I use Turborepo?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"How do I keep multiple apps sharing the same UI?"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you have written your first screen... you have already spent hours making architectural decisions. Not product decisions. Not business decisions. &lt;strong&gt;Just project setup.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Invisible Tax Every React Native Developer Pays
&lt;/h2&gt;

&lt;p&gt;Imagine you are opening a restaurant. You have recipes. You have chefs. You know exactly what food you want to serve. But before opening the doors, someone tells you:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"First, build the kitchen."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So you start researching: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which stove?&lt;/li&gt;
&lt;li&gt;Which oven?&lt;/li&gt;
&lt;li&gt;Which refrigerator?&lt;/li&gt;
&lt;li&gt;How should the plumbing work?&lt;/li&gt;
&lt;li&gt;How do multiple restaurants share ingredients?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Months later... you still haven't served a single customer.&lt;/p&gt;

&lt;p&gt;Software development often feels exactly like that. We don't struggle because building apps is hard. &lt;strong&gt;We struggle because building the environment to build apps is hard.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Every New Project Starts With The Same Questions
&lt;/h2&gt;

&lt;p&gt;Every engineering team eventually reaches the same crossroads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How should we organize this codebase?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;Should every app have its own repository?&lt;/li&gt;
&lt;li&gt;Or should they share everything?&lt;/li&gt;
&lt;li&gt;Should design components live inside the mobile app folder, or inside separate workspace packages?&lt;/li&gt;
&lt;li&gt;What about authentication, API clients, themes, icons, and utilities?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every answer seems correct... until six months later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then The Real Problems Begin
&lt;/h2&gt;

&lt;p&gt;Imagine your company builds two applications: a &lt;strong&gt;Customer App&lt;/strong&gt; and a &lt;strong&gt;POS App&lt;/strong&gt;. Both need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Buttons &amp;amp; Forms&lt;/li&gt;
&lt;li&gt;A consistent design theme&lt;/li&gt;
&lt;li&gt;Icons &amp;amp; Typography&lt;/li&gt;
&lt;li&gt;Authentication plumbing&lt;/li&gt;
&lt;li&gt;A typed API client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At first, copy-pasting feels faster. Until the designer changes one button style. Now your codebase looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer App  👉  Updated ✅
POS App       👉  Outdated ❌

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next week, the typography changes. Then the color palette. Then spacing. Suddenly, you are spending your weekends maintaining two separate design systems that were supposed to be one.&lt;/p&gt;




&lt;h2&gt;
  
  
  "Let's Make a Shared UI Package"
&lt;/h2&gt;

&lt;p&gt;Sounds easy. Until you actually try to set it up in a mobile environment. Now you are diving deep into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm / pnpm&lt;/strong&gt; workspaces&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo&lt;/strong&gt; pipeline caching&lt;/li&gt;
&lt;li&gt;Package export maps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metro bundler&lt;/strong&gt; configuration&lt;/li&gt;
&lt;li&gt;TypeScript path aliases &amp;amp; references&lt;/li&gt;
&lt;li&gt;Babel plugins &amp;amp; module resolution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You started because you wanted one shared button. Now you are spending your evening debugging &lt;strong&gt;why Metro cannot resolve an SVG icon&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Styling Was Supposed To Be Easy, Too
&lt;/h2&gt;

&lt;p&gt;Most web developers already know and love &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind CSS&lt;/a&gt;. So naturally, they ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Can I just use Tailwind in React Native?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer is yes. Then comes another rabbit hole: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Babel plugins&lt;/li&gt;
&lt;li&gt;Metro config adjustments&lt;/li&gt;
&lt;li&gt;Dark mode persistence&lt;/li&gt;
&lt;li&gt;Theme CSS variables&lt;/li&gt;
&lt;li&gt;Hot reload bugs&lt;/li&gt;
&lt;li&gt;And version compatibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When &lt;a href="https://www.nativewind.dev/v5" rel="noopener noreferrer"&gt;NativeWind v5&lt;/a&gt; arrived, it solved many long-standing pain points and made styling feel much closer to the web Tailwind experience developers love. But getting everything configured correctly - and keeping it working smoothly across SDK upgrades - still takes considerable time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The styling itself isn't the hard part. The boilerplate setup is.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Building Components Is Harder Than It Looks
&lt;/h2&gt;

&lt;p&gt;Most production apps don't need fancy, animated components. They need &lt;strong&gt;consistent, accessible components&lt;/strong&gt;. A button should always look and feel like a button. An input should always handle keyboard focus correctly. A modal dialog should behave predictably across iOS and Android.&lt;/p&gt;

&lt;p&gt;Projects like &lt;a href="https://reactnativereusables.com/" rel="noopener noreferrer"&gt;React Native Reusables&lt;/a&gt; made this dramatically easier by bringing modern, accessible UI primitives into the React Native ecosystem. Instead of reinventing custom components for every project, you start with well-designed building blocks.&lt;/p&gt;

&lt;p&gt;But even then... someone still has to integrate, theme, and wire everything together across version upgrades.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ The NativeWind v5 Migration Gap (And How RNStack Solves It)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now, React Native Reusables officially targets &lt;strong&gt;NativeWind v4&lt;/strong&gt;. If you try using it with &lt;strong&gt;NativeWind v5&lt;/strong&gt;, you will immediately hit styling bugs and breaking changes.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;RNStack&lt;/strong&gt;, I have already &lt;strong&gt;fully migrated all included components to NativeWind v5&lt;/strong&gt; (Tailwind v4) and pre-fixed common problem areas—especially complex components like &lt;code&gt;Icon&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt;, and &lt;code&gt;Select&lt;/code&gt;—so they render seamlessly across iOS, Android, and web out of the box.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  And Then Comes Scale
&lt;/h2&gt;

&lt;p&gt;Your first app becomes successful. Now the business asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Can we build another app for our internal team?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Great! Except now your architectural requirements have multiplied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;📱 Customer App   |   🛠️ Admin App   |   🏪 POS App   |   🚚 Driver App

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four applications. One engineering team. One design language. One backend API. One authentication system.&lt;/p&gt;

&lt;p&gt;Should you create four separate repositories? Or one? This is where &lt;strong&gt;monorepos&lt;/strong&gt; stop being a buzzword and start becoming a practical survival tool.&lt;/p&gt;

&lt;p&gt;A monorepo isn't just about putting everything into one giant folder. It is about giving every project access to the exact same foundation: &lt;strong&gt;One shared UI. One shared API layer. One shared configuration. One source of truth.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  But Monorepos Bring Their Own Challenges
&lt;/h2&gt;

&lt;p&gt;Large repositories can easily become painfully slow. Without proper tooling, every build starts rebuilding everything from scratch, and every dependency install feels heavier.&lt;/p&gt;

&lt;p&gt;That is where &lt;a href="https://turborepo.dev/docs" rel="noopener noreferrer"&gt;Turborepo&lt;/a&gt; changes the story. Instead of rebuilding the entire house every time you make a change, &lt;strong&gt;it only rebuilds the rooms you have actually touched&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result is a development workflow that stays lightning-fast even as your codebase grows. Not because your apps became smaller—but because your tooling became smarter.&lt;/p&gt;




&lt;h2&gt;
  
  
  After Solving The Same Problems Again And Again...
&lt;/h2&gt;

&lt;p&gt;I noticed a pattern. Every project I worked on looked different on the surface... but the foundational setup looked almost identical.&lt;/p&gt;

&lt;p&gt;Every time I started a new React Native project, I found myself &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;copying the same workspace files&lt;/li&gt;
&lt;li&gt;installing the exact same dependencies&lt;/li&gt;
&lt;li&gt;configuring the same Metro resolver fixes&lt;/li&gt;
&lt;li&gt;setting up the same TypeScript aliases&lt;/li&gt;
&lt;li&gt;building the same shared UI package&lt;/li&gt;
&lt;li&gt;and configuring &lt;a href="https://docs.expo.dev/router/introduction/" rel="noopener noreferrer"&gt;Expo Router&lt;/a&gt; and NativeWind. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over and over again.&lt;/p&gt;

&lt;p&gt;Eventually, I stopped asking: &lt;em&gt;"How do I start another project?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Instead, I asked: &lt;strong&gt;"Why am I solving this plumbing problem every single time?"&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  That's Why I Built RNStack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sanjaysah101/rnstack" rel="noopener noreferrer"&gt;RNStack&lt;/a&gt; isn't trying to replace Expo. It isn't another heavy UI library. It isn't a random collection of disconnected snippets.&lt;/p&gt;

&lt;p&gt;It is simply the &lt;strong&gt;mobile-first, production-ready monorepo foundation&lt;/strong&gt; I wished every new React Native project started with. A clean architecture where the difficult, time-consuming setup decisions have already been made for you. So you can focus on building your actual product instead of wrestling with build tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  One Command To Start
&lt;/h2&gt;

&lt;p&gt;You can scaffold a clean, production-ready project instantly using the &lt;a href="https://www.npmjs.com/package/create-rnstack" rel="noopener noreferrer"&gt;create-rnstack npm package&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm create rnstack my-app

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few moments later, you have a fully wired workspace that already includes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Pre-Configured Tooling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://expo.dev/" rel="noopener noreferrer"&gt;Expo&lt;/a&gt; (SDK 56) + &lt;a href="https://docs.expo.dev/router/introduction/" rel="noopener noreferrer"&gt;Expo Router&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Styling &amp;amp; UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nativewind.dev/v5" rel="noopener noreferrer"&gt;NativeWind v5&lt;/a&gt; + &lt;a href="https://reactnativereusables.com/" rel="noopener noreferrer"&gt;React Native Reusables&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Architecture&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://turborepo.dev/docs" rel="noopener noreferrer"&gt;Turborepo&lt;/a&gt; + pnpm Workspaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared Packages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-linked &lt;code&gt;@repo/ui&lt;/code&gt;, &lt;code&gt;@repo/api-client&lt;/code&gt;, and &lt;code&gt;@repo/config&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code Quality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Strict TypeScript, Biome, Husky&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reliability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Automatic Expo dependency validation &amp;amp; native bundle ID management&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No hunting through outdated Medium tutorials. No copy-pasting broken configurations. No wondering whether you forgot a crucial setup step.&lt;/p&gt;




&lt;h2&gt;
  
  
  Small Details Matter
&lt;/h2&gt;

&lt;p&gt;The biggest improvements in developer experience are often the ones you never notice because everything just works. RNStack automatically handles the subtle details that are easy to forget but painful to fix later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Unique Native Bundle IDs:&lt;/strong&gt; Every generated app automatically gets a clean, unique bundle identifier.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Aligned SDK Versions:&lt;/strong&gt; Expo dependency versions stay strictly aligned with your installed SDK.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Immediate Git Hooks:&lt;/strong&gt; Pre-commit formatting and linting work right out of the box.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Zero Bloat:&lt;/strong&gt; Projects start clean without shipping dozens of unnecessary boilerplate demo screens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't flashy marketing features. They are the quiet, architectural details that save dozens of hours over the lifetime of a production codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Goal Was Never To Build Just Another Boilerplate
&lt;/h2&gt;

&lt;p&gt;There are already plenty of starters out there. But most of them stop right after generating a few static files.&lt;/p&gt;

&lt;p&gt;RNStack tries to go one step further: it gives you an evolving foundation meant to survive far beyond your first commit. Something robust enough to build your next weekend side project on, scale into a startup MVP, or ship to thousands of production users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Out &amp;amp; Join The Journey
&lt;/h2&gt;

&lt;p&gt;If you are starting a new React Native project this week, give RNStack a spin. I would genuinely love to hear your feedback—what feels intuitive, what feels awkward, and what could be improved!&lt;/p&gt;

&lt;p&gt;Every issue opened, suggestion made, and pull request submitted helps make the mobile development experience better for the next engineer.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔗 Get Started in Seconds
&lt;/h3&gt;

&lt;p&gt;Scaffold your new project right now from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm create rnstack my-app

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;📦 NPM Package:&lt;/strong&gt; Check out the CLI documentation on the &lt;a href="https://www.npmjs.com/package/create-rnstack" rel="noopener noreferrer"&gt;create-rnstack npm page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;⭐ GitHub Repository:&lt;/strong&gt; Explore the monorepo architecture, star the repo, or contribute on &lt;a href="https://github.com/sanjaysah101/rnstack" rel="noopener noreferrer"&gt;sanjaysah101/rnstack&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If RNStack saves you a few hours—or helps you skip a frustrating evening of configuration—that is exactly why I built it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Happy building! 🚀&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>react</category>
      <category>opensource</category>
    </item>
    <item>
      <title>BrewOps: A Production-Grade HTCPCP Dashboard</title>
      <dc:creator>Sanjay Kumar Sah</dc:creator>
      <pubDate>Thu, 02 Apr 2026 11:40:36 +0000</pubDate>
      <link>https://dev.to/sanjaysah/brewops-a-production-grade-htcpcp-dashboard-l35</link>
      <guid>https://dev.to/sanjaysah/brewops-a-production-grade-htcpcp-dashboard-l35</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/aprilfools-2026"&gt;DEV April Fools Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I built &lt;strong&gt;BrewOps&lt;/strong&gt;, a highly serious, production-grade DevOps dashboard for a delightfully useless protocol: the Hyper Text Coffee Pot Control Protocol (HTCPCP, RFC 2324). &lt;/p&gt;

&lt;p&gt;Tired of walking to the breakroom only to find the coffee pot empty? BrewOps brings 1998's best internet joke into the modern era. It's a sleek control center that lets you monitor your network of coffee pots and teapots. You can issue &lt;code&gt;BREW&lt;/code&gt; and &lt;code&gt;PROPFIND&lt;/code&gt; requests, select your &lt;code&gt;Accept-Additions&lt;/code&gt; (like Milk, Syrup, or Alcohol), and watch the live terminal logs. &lt;/p&gt;

&lt;p&gt;And yes, if you try to brew coffee using the "Earl Grey Teapot" appliance, the server will correctly reject your request with a &lt;code&gt;418 I'm a teapot&lt;/code&gt; status code, complete with a panic animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live App:&lt;/strong&gt; &lt;a href="https://brewops-htcpcp-dashboard-775853986076.us-west1.run.app" rel="noopener noreferrer"&gt;BrewOps HTCPCP Dashboard&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag__cloud-run"&gt;
  &lt;iframe height="600px" src="https://brewops-htcpcp-dashboard-775853986076.us-west1.run.app"&gt;
  &lt;/iframe&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a href="https://ai.studio/apps/1e2bc60e-2228-47dc-9b84-4da88097bbf4" rel="noopener noreferrer"&gt;Google AI Studio Project&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;I built this using &lt;strong&gt;Next.js&lt;/strong&gt; and &lt;strong&gt;Tailwind CSS&lt;/strong&gt; to give it that authentic, dark-mode "serious developer tool" aesthetic. The icons are from &lt;code&gt;lucide-react&lt;/code&gt;, and I used &lt;code&gt;motion/react&lt;/code&gt; (Framer Motion) to create the smooth terminal log entries and the bouncing teapot animation when a 418 error is triggered. &lt;/p&gt;

&lt;p&gt;The entire project was generated, iterated on, and deployed using &lt;strong&gt;Google AI Studio&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;p&gt;I am submitting this for two categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Best Ode to Larry Masinter:&lt;/strong&gt; &lt;br&gt;
This project is a literal, playable implementation of Larry Masinter's legendary RFC 2324. It faithfully recreates the HTCPCP headers (&lt;code&gt;Accept-Additions&lt;/code&gt;, &lt;code&gt;message/coffeepot&lt;/code&gt; content types) and intentionally triggers the famous &lt;code&gt;418 I'm a teapot&lt;/code&gt; error when you target the wrong appliance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Best Google AI Usage:&lt;/strong&gt; &lt;br&gt;
I built this entire application from scratch using &lt;strong&gt;Google AI Studio&lt;/strong&gt; (powered by Gemini 3.1 Pro). The AI agent helped me scaffold the Next.js app, design the Tailwind UI, write the simulated terminal logic, and instantly deploy the final build to &lt;strong&gt;Google Cloud Run&lt;/strong&gt; (which is where it is currently hosted!).&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>418challenge</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
