<?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: Diven Rastdus</title>
    <description>The latest articles on DEV Community by Diven Rastdus (@astraedus).</description>
    <link>https://dev.to/astraedus</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%2F3807319%2F88334a5b-4b5d-412a-a196-402f05bca721.png</url>
      <title>DEV Community: Diven Rastdus</title>
      <link>https://dev.to/astraedus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/astraedus"/>
    <language>en</language>
    <item>
      <title>5 Python 3.14 Features That Change How You Write Code in 2026 (And 2 I'm Still Waiting For)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 01 Jul 2026 10:14:12 +0000</pubDate>
      <link>https://dev.to/astraedus/5-python-314-features-that-change-how-you-write-code-in-2026-and-2-im-still-waiting-for-30mn</link>
      <guid>https://dev.to/astraedus/5-python-314-features-that-change-how-you-write-code-in-2026-and-2-im-still-waiting-for-30mn</guid>
      <description>&lt;p&gt;Python 3.14 lets you delete a line from the top of almost every file you own: &lt;code&gt;from __future__ import annotations&lt;/code&gt;. That is one of five changes in the October 2025 release that actually reach the code you write daily. The rest: threads that use every core, strings that cannot be SQL-injected, a debugger you attach to a live process, and one dependency you can finally uninstall. I run our whole automation stack on Python, so I upgraded and kept notes on what changed in practice. Here is the short list that matters, with runnable examples.&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%2Fmg0g13h55gixjw1yx4jw.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%2Fmg0g13h55gixjw1yx4jw.png" alt="Map of the 5 Python 3.14 features covered in this article" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;from __future__ import annotations&lt;/code&gt; is finally dead
&lt;/h2&gt;

&lt;p&gt;Python 3.14 stops evaluating annotations when a function or class is defined, so forward references just work and you no longer need the future import. This is PEP 649 and PEP 749, and it is the change most likely to touch your existing code.&lt;/p&gt;

&lt;p&gt;Before, referencing a type before it existed raised &lt;code&gt;NameError&lt;/code&gt;, and the fix was to quote the type or add the future import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python 3.13 and earlier: NameError at class-definition time
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# NameError: Node isn't defined yet
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="c1"&gt;# the old workaround was to quote it: child: "Node"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 3.14 that class body runs as-is. Annotations are stored and only computed when something asks for them. When you do want to read them, the new &lt;code&gt;annotationlib&lt;/code&gt; module gives you three formats instead of one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;annotationlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_annotations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Format&lt;/span&gt;

&lt;span class="nf"&gt;get_annotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# {'child': 'Node', 'return': 'None'}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your codebase starts every module with &lt;code&gt;from __future__ import annotations&lt;/code&gt;, you can start deleting those lines. Tools like Pydantic and dataclasses benefit for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Template strings kill the f-string injection footgun
&lt;/h2&gt;

&lt;p&gt;Template strings, written with a &lt;code&gt;t&lt;/code&gt; prefix, return the parts of the string before they are joined, so you can escape or validate every interpolated value. This is PEP 750, and it is the fix for the oldest f-string footgun: pasting user input straight into SQL, HTML, or a shell command.&lt;/p&gt;

&lt;p&gt;An f-string joins everything immediately, which is exactly what you do not want here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;; DROP TABLE users; --&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select * from users where name = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;  &lt;span class="c1"&gt;# already unsafe
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A t-string hands you a &lt;code&gt;Template&lt;/code&gt; object instead of a finished string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;; DROP TABLE users; --&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select * from users where name = {name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;class 'string.templatelib.Template'&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you decide how each part is rendered. Iterating a &lt;code&gt;Template&lt;/code&gt; yields the static text as plain strings and every interpolation as an object you can escape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Static text is trusted; interpolated values get escaped
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# literal SQL, safe
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# your escaping function
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The static parts pass through untouched, the values run through &lt;code&gt;quote&lt;/code&gt;, and the result is a safe-by-construction string that reads exactly like an f-string.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. No-GIL is official: threads that use every core
&lt;/h2&gt;

&lt;p&gt;Free-threaded (no-GIL) Python is now an officially supported build in 3.14, though it stays opt-in rather than the default interpreter. CPU-bound threads run in true parallel on it for the first time. This is PEP 779. In earlier versions the global interpreter lock forced Python threads to take turns, so &lt;code&gt;threading&lt;/code&gt; only helped for I/O-bound work.&lt;/p&gt;

&lt;p&gt;You opt in with the free-threaded build (&lt;code&gt;python3.14t&lt;/code&gt;), and you can check the state at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_gil_enabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# False on the free-threaded build
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now this actually saturates your cores instead of one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crunch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crunch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10_000_000&lt;/span&gt;&lt;span class="p"&gt;]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is honest, and the CPython team publishes it: single-threaded code runs about 5 to 10 percent slower on the free-threaded build. For a service that fans work across threads, that is a great deal. Check that your C-extension dependencies ship free-threaded wheels before you switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Attach a debugger to a live process, no restart
&lt;/h2&gt;

&lt;p&gt;Python 3.14 lets you drop into a debugger inside an already-running process with &lt;code&gt;python -m pdb -p &amp;lt;PID&amp;gt;&lt;/code&gt;. This is PEP 768, and it is the feature I did not know I needed until the first time a background job hung in production.&lt;/p&gt;

&lt;p&gt;No restart, no adding &lt;code&gt;breakpoint()&lt;/code&gt; and redeploying, no scattering print statements and waiting for the bug to happen again:&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="c"&gt;# Find the stuck process, then attach a live pdb session to it&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; pdb &lt;span class="nt"&gt;-p&lt;/span&gt; 4242
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood this uses a new &lt;code&gt;sys.remote_exec()&lt;/code&gt; interface with real safety controls. You can lock it down in hardened environments:&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="c"&gt;# Disable remote attaching entirely&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYTHON_DISABLE_REMOTE_DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being able to inspect a live process instead of trying to reproduce its state later is a genuine change to how you debug long-running Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Zstandard is in the stdlib, so &lt;code&gt;pip install zstandard&lt;/code&gt; can go
&lt;/h2&gt;

&lt;p&gt;Python 3.14 adds &lt;code&gt;compression.zstd&lt;/code&gt;, a built-in module for Zstandard compression, which means one fewer third-party package for a very common job. This is PEP 784. Zstandard gives you gzip-level ratios at much higher speed, and until now you had to &lt;code&gt;pip install zstandard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The API matches the other compression modules, so it is instantly familiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;compression&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zstd&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log line that repeats a lot&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="n"&gt;packed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard &lt;code&gt;tarfile&lt;/code&gt;, &lt;code&gt;zipfile&lt;/code&gt;, and &lt;code&gt;shutil&lt;/code&gt; modules learned to read and write Zstandard archives too, so &lt;code&gt;.tar.zst&lt;/code&gt; files work with the tools you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still waiting for
&lt;/h2&gt;

&lt;p&gt;Two things landed as "here, but not yet the default," and both are on my wishlist for the next release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The JIT on by default.&lt;/strong&gt; Python 3.14 ships an experimental just-in-time compiler in the official Windows and macOS binaries, but it stays off unless you ask for it:&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="c"&gt;# Opt in to the experimental JIT&lt;/span&gt;
&lt;span class="nv"&gt;PYTHON_JIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 python my_script.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is promising, but "experimental and off by default" means most people never see it. I want a JIT that is stable and automatic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free-threading as the plain &lt;code&gt;python&lt;/code&gt;.&lt;/strong&gt; Right now no-GIL is a separate build, and a lot of the ecosystem still needs per-package free-threaded wheels before it is safe to switch. I want the day &lt;code&gt;python&lt;/code&gt; just means free-threaded, with the whole wheel ecosystem shipping compatible builds so nobody has to think about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Python 3.14 is quietly one of the most practical releases in years. If you upgrade, start by deleting your &lt;code&gt;from __future__ import annotations&lt;/code&gt; lines, reach for &lt;code&gt;t"..."&lt;/code&gt; anywhere you build SQL or HTML, and benchmark your CPU-bound paths on the free-threaded build before you commit to it. All five changes are worth the version bump on their own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>TypeScript vs JavaScript in 2026: Now That Node Runs .ts Files Directly</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 29 Jun 2026 10:15:48 +0000</pubDate>
      <link>https://dev.to/astraedus/typescript-vs-javascript-in-2026-now-that-node-runs-ts-files-directly-1m2l</link>
      <guid>https://dev.to/astraedus/typescript-vs-javascript-in-2026-now-that-node-runs-ts-files-directly-1m2l</guid>
      <description>&lt;p&gt;Use TypeScript for code you will still run next month. Use plain JavaScript for code you will throw away by Friday. That is the whole answer, and it has been the answer for a while.&lt;/p&gt;

&lt;p&gt;The part worth reading is where that line sits in 2026, because it moved. Bun and Deno have run TypeScript directly for years. In late 2024 and into 2025, Node finally caught up and joined them. That quietly deleted the single biggest reason people reached for plain JavaScript: getting started fast.&lt;/p&gt;

&lt;p&gt;I ship mobile and web apps in TypeScript and reach for plain JavaScript on quick scripts. The decision shows up every time I create a file. Here is how I make the call now, and why the two languages are closer than they have ever been.&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%2Fuwf5rsxjhjs7nv2ydy3i.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%2Fuwf5rsxjhjs7nv2ydy3i.png" alt="Pick by lifespan: throwaway code maps to plain JavaScript, revisited code to JavaScript plus JSDoc, maintained code to TypeScript" width="799" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in 2025: your runtime reads TypeScript
&lt;/h2&gt;

&lt;p&gt;The build step is mostly gone. Node 23.6 made native type stripping work with no flag, so &lt;code&gt;node app.ts&lt;/code&gt; runs without &lt;code&gt;tsc&lt;/code&gt;, &lt;code&gt;ts-node&lt;/code&gt;, or a bundler. Recent Node 22 LTS builds do it too.&lt;/p&gt;

&lt;p&gt;Here is the whole setup:&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="c"&gt;# Node 22.6 added --experimental-strip-types.&lt;/span&gt;
&lt;span class="c"&gt;# Node 23.6 made it the default. This just works:&lt;/span&gt;
node app.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type stripping does exactly what the name says. It removes the type annotations and leaves the JavaScript untouched, so your &lt;code&gt;.ts&lt;/code&gt; file becomes valid JS at parse time with no separate compile.&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;// app.ts, runs directly on modern Node, Deno, and Bun&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;greet&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`hello &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;`&lt;/span&gt;&lt;span class="p"&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="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world&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;Two honest catches. First, stripping runs your code but does not check it. The types are deleted, not verified, so you still run &lt;code&gt;tsc --noEmit&lt;/code&gt; or lean on your editor to catch type errors. Second, stripping cannot handle syntax that emits real code, like &lt;code&gt;enum&lt;/code&gt; and &lt;code&gt;namespace&lt;/code&gt;. Those you compile with &lt;code&gt;tsc&lt;/code&gt; or avoid. Skip them and your TypeScript runs anywhere JavaScript runs. The TypeScript team turned that second catch into a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript earns its keep when code outlives the week
&lt;/h2&gt;

&lt;p&gt;TypeScript wins the moment more than one person, or more than one week, touches the code. The payoff is not "fewer bugs" in the abstract. It is the specific bug that ships because a value had the wrong shape and nobody noticed.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amountCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Plain JS: this runs, then misbehaves in production.&lt;/span&gt;
&lt;span class="c1"&gt;// You swapped the arguments and nothing warned you.&lt;/span&gt;
&lt;span class="nf"&gt;sendInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// TypeScript: red squiggle before you ever save.&lt;/span&gt;
&lt;span class="c1"&gt;// Argument of type 'number' is not assignable to 'string'.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bigger win is refactoring. Rename a field on a shared type and the compiler hands you every one of the forty places that now break. On our own apps the TypeScript line pays for itself the first time a refactor touches that many files. Autocomplete that actually knows your data is the daily version of the same payoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the type tax is not worth paying
&lt;/h2&gt;

&lt;p&gt;Plain JavaScript still wins for code with a short life and a single author. A thirty line script does not earn a &lt;code&gt;tsconfig.json&lt;/code&gt;, a pile of &lt;code&gt;@types&lt;/code&gt; packages, and a fight with a generic. The type tax is real, and on throwaway work it never pays back.&lt;/p&gt;

&lt;p&gt;The same goes for learning, for a quick prototype you will rewrite anyway, and for the small config file nobody touches again. Reaching for TypeScript there is ceremony, not safety.&lt;/p&gt;

&lt;p&gt;If you want a middle ground, JSDoc gives you type checking with zero new syntax:&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;// @ts-check&lt;/span&gt;
&lt;span class="cm"&gt;/** @param {string} name */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;greet&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`hello &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;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// editor flags this, and the file is still plain .js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;// @ts-check&lt;/code&gt; to the top of a &lt;code&gt;.js&lt;/code&gt; file and your editor checks it against JSDoc comments. You get the safety net without the build story. Plenty of large projects ship exactly this way on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two languages are converging
&lt;/h2&gt;

&lt;p&gt;TypeScript and JavaScript are growing toward each other, not apart. The clearest signal is a flag added in TypeScript 5.8: &lt;code&gt;erasableSyntaxOnly&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tsconfig.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"erasableSyntaxOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn it on and TypeScript refuses to compile the parts that are not pure types: &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt; with runtime values, constructor parameter properties, and &lt;code&gt;import =&lt;/code&gt; aliases. It bans exactly the four constructs that native type stripping cannot run. The replacement for an enum is a plain union:&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;// Not erasable, emits a runtime object:&lt;/span&gt;
&lt;span class="kr"&gt;enum&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Erasable, disappears completely after stripping:&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&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;This is the TypeScript team steering the language toward "JavaScript with types you can delete." It lines up with the TC39 Type Annotations proposal, a Microsoft backed effort to let JavaScript engines treat type syntax as comments. That one has sat at Stage 1 since 2023 and stalled there, partly because native type stripping already solved the same problem in practice.&lt;/p&gt;

&lt;p&gt;Tooling is moving the same way. TypeScript 6.0 shipped in March 2026 as the last release built on the original compiler. Its successor, TypeScript 7.0, is a native port written in Go that Microsoft says is about ten times faster to build. The friction that made TypeScript feel heavy is getting designed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which should you use in 2026?
&lt;/h2&gt;

&lt;p&gt;Default to TypeScript for anything you will maintain, and reach for plain JavaScript or JSDoc for anything you will not. The decision is about lifespan and blast radius, not ideology.&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%2Fyrhv13q567wuq6im0is9.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%2Fyrhv13q567wuq6im0is9.png" alt="Decision flow: if a file is maintained past this week use TypeScript, otherwise plain JavaScript for one-off files or JSDoc for scripts you will revisit" width="800" height="752"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run the file through one question: will you still be running this past this week? If yes, the safety, the autocomplete, and the safe refactors pay for themselves, and your runtime executes the &lt;code&gt;.ts&lt;/code&gt; file directly anyway. If no, and it is a single file or a quick prototype, plain JavaScript keeps you fast. The in-between case, a script you will revisit but do not want to configure, is what JSDoc is for.&lt;/p&gt;

&lt;p&gt;The old framing was a war: dynamic freedom against static safety. That war is over. TypeScript won the code you keep, JavaScript owns the code you toss, and native type stripping erased the wall that used to stand between them. Pick by how long the code will live, and you will be right almost every time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I make this call every time I create a file, building apps and tools at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;. If you have a real case where the lifespan rule breaks, I want to hear it: &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox, &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>5 Cron Mistakes That Silently Break Your Scheduled Jobs</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 29 Jun 2026 01:05:40 +0000</pubDate>
      <link>https://dev.to/astraedus/5-cron-mistakes-that-silently-break-your-scheduled-jobs-4e05</link>
      <guid>https://dev.to/astraedus/5-cron-mistakes-that-silently-break-your-scheduled-jobs-4e05</guid>
      <description>&lt;p&gt;It's 3am. The job you set up three weeks ago was supposed to run at 9am every day. You check the logs at noon and find: nothing. No error. No output. Just silence.&lt;/p&gt;

&lt;p&gt;That's the thing about cron failures: they don't crash, they don't alert, they just don't happen. And you find out hours (or days) later when someone notices the data didn't update or the emails didn't go out.&lt;/p&gt;

&lt;p&gt;Here are the five mistakes that cause this. All real, all fixable.&lt;/p&gt;

&lt;p&gt;First, the one idea that explains all five: cron does not run in your shell.&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%2F8haiola7uoly7orlxpj2.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%2F8haiola7uoly7orlxpj2.png" alt="The environment cron actually runs in vs what you picture in your head" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The timezone trap
&lt;/h2&gt;

&lt;p&gt;Your crontab line says &lt;code&gt;0 9 * * *&lt;/code&gt;. You expect it to fire at 9am. It fires at 7pm instead.&lt;/p&gt;

&lt;p&gt;Cron fires this job when the daemon's clock reads 09:00. On a UTC server, that's 09:00 UTC. In UTC+10, that's 7pm your time, the same day. You wanted 9am local, you got 7pm local: off by exactly your UTC offset. Flip the sign and you'll know which way it slips.&lt;/p&gt;

&lt;p&gt;First: check your daemon's actual timezone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;timedatectl
&lt;span class="c"&gt;# or just:&lt;/span&gt;
&lt;span class="nb"&gt;date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set &lt;code&gt;CRON_TZ&lt;/code&gt; explicitly at the top of your crontab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRON_TZ=America/New_York
0 9 * * * /path/to/job.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most portable approach is to schedule everything in UTC deliberately and convert mentally. UTC doesn't shift for daylight savings. Your jobs fire predictably.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The bare PATH problem
&lt;/h2&gt;

&lt;p&gt;Your script runs perfectly from the terminal. In cron, it fails silently with &lt;code&gt;command not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Cron starts with a stripped-down environment. No &lt;code&gt;.bashrc&lt;/code&gt;, no &lt;code&gt;.profile&lt;/code&gt;, nothing sourced. The default &lt;code&gt;PATH&lt;/code&gt; is usually just &lt;code&gt;/usr/bin:/bin&lt;/code&gt;. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node&lt;/code&gt; via nvm: not there&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python3&lt;/code&gt; from a virtualenv: not there&lt;/li&gt;
&lt;li&gt;Custom binaries in &lt;code&gt;~/bin&lt;/code&gt;: not there&lt;/li&gt;
&lt;li&gt;Any tool that depends on a configured &lt;code&gt;PATH&lt;/code&gt;: broken&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is explicit exports at the top of every script cron runs:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NVM_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.nvm"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NVM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/nvm.sh"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NVM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/nvm.sh"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/youruser/.nvm/versions/node/v20.0.0/bin:/usr/local/bin:/usr/bin:/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or use full absolute paths in the crontab itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 * * * /home/youruser/.nvm/versions/node/v20.0.0/bin/node /path/to/script.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The absolute-path approach is brittle when you update Node. The export at the top of the script is cleaner for anything non-trivial. Pick one, be consistent.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. No year field means one-shots repeat forever
&lt;/h2&gt;

&lt;p&gt;You want to run a migration once, on a specific date. You add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 10 4 7 * /path/to/migration.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs on July 4th. Then again next year. And the year after.&lt;/p&gt;

&lt;p&gt;Crontab has no year field. The five columns are: minute, hour, day-of-month, month, day-of-week. There's no way to express "only 2026." Everything in a crontab is a recurring schedule.&lt;/p&gt;

&lt;p&gt;For genuine one-shots, use &lt;code&gt;systemd-run&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--on-calendar&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-04 10:00:00"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;migration-july4 &lt;span class="se"&gt;\&lt;/span&gt;
  /path/to/migration.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fires once, never repeats. The transient unit will still appear in &lt;code&gt;systemctl --user list-timers&lt;/code&gt; until the session restarts or you run &lt;code&gt;systemctl --user reset-failed migration-july4&lt;/code&gt; -- harmless, but don't be surprised to see it listed.&lt;/p&gt;

&lt;p&gt;If systemd isn't available, the &lt;code&gt;at&lt;/code&gt; command works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/migration.sh"&lt;/span&gt; | at 10:00 Jul 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;at&lt;/code&gt; isn't installed on a lot of minimal server images (&lt;code&gt;apt install at&lt;/code&gt; / &lt;code&gt;apk add at&lt;/code&gt; first), and month parsing is finicky -- &lt;code&gt;Jul&lt;/code&gt; is safer than &lt;code&gt;July&lt;/code&gt;. The fallback of "have the script delete its own crontab line" also works but is fragile. Prefer &lt;code&gt;systemd-run&lt;/code&gt; or &lt;code&gt;at&lt;/code&gt; for anything that should only run once.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Silent failure, no one watching
&lt;/h2&gt;

&lt;p&gt;A cron job runs. It exits with code 1. Nothing happens. No alert, no log, no indication anything went wrong.&lt;/p&gt;

&lt;p&gt;By default, cron sends stdout and stderr to a local mail spool at &lt;code&gt;/var/spool/mail/youruser&lt;/code&gt;. Nobody reads that. If &lt;code&gt;sendmail&lt;/code&gt; isn't configured, output goes nowhere.&lt;/p&gt;

&lt;p&gt;Your job can fail every single run for a week and you'd never know.&lt;/p&gt;

&lt;p&gt;Minimum viable fix: redirect output to a log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 9 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /path/to/job.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/myjob.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add timestamps so you can tell when each run happened:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] Starting..."&lt;/span&gt;
&lt;span class="c"&gt;# ... your work ...&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] Done."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything that actually matters, add a dead-man's switch. &lt;a href="https://healthchecks.io" rel="noopener noreferrer"&gt;Healthchecks.io&lt;/a&gt; is free for small setups. Your job pings a URL on success. If no ping arrives in the expected window, you get alerted.&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; /path/to/actual-work.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 https://hc-ping.com/your-uuid &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between "job dead for 24 hours before we noticed" and "paged within 5 minutes" is that one curl call at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Overlapping runs
&lt;/h2&gt;

&lt;p&gt;Your job is scheduled every 5 minutes. Runs take 2 minutes, usually. But occasionally a database is slow and it takes 7. Now a second copy starts on top of the first.&lt;/p&gt;

&lt;p&gt;Two instances processing the same queue, writing to the same table, sending the same emails.&lt;/p&gt;

&lt;p&gt;The fix is a lockfile. An atomic &lt;code&gt;mkdir&lt;/code&gt; is the most portable pattern:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;LOCK_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/myjob.lock"&lt;/span&gt;

&lt;span class="c"&gt;# Try to acquire the lock&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] Already running, exiting."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Release on exit, including crashes&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s2"&gt;"rmdir '&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_DIR&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt; EXIT

&lt;span class="c"&gt;# ... your actual job logic ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;trap&lt;/code&gt; on &lt;code&gt;EXIT&lt;/code&gt; is critical. If your script crashes mid-run, the lock gets cleaned up automatically. Without it, the lock directory persists and the job never runs again until someone manually deletes it.&lt;/p&gt;

&lt;p&gt;If you know &lt;code&gt;flock&lt;/code&gt; is available:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;200&amp;gt;/tmp/myjob.flock
flock &lt;span class="nt"&gt;-n&lt;/span&gt; 200 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"already running"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# ... your job logic ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either works. The &lt;code&gt;mkdir&lt;/code&gt; approach is more portable; &lt;code&gt;flock&lt;/code&gt; is marginally cleaner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these all share the same root cause
&lt;/h2&gt;

&lt;p&gt;Most of these failures share a single root: cron doesn't run in your shell environment. It runs in a bare, minimal execution context with no profile, a stripped PATH, a daemon timezone, and output going nowhere. The gap between what runs on your terminal and what runs in cron is where all of this lives.&lt;/p&gt;

&lt;p&gt;Before adding any new cron job, run through this:&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%2Fk2divdwy73ilkz7hbys7.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%2Fk2divdwy73ilkz7hbys7.png" alt="cron pre-flight checklist: timezone, PATH, one-shot, logging, lockfile" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Timezone set explicitly (&lt;code&gt;CRON_TZ=&lt;/code&gt; or scheduled in UTC)&lt;/li&gt;
&lt;li&gt;[ ] Full PATH exported at the top of the script&lt;/li&gt;
&lt;li&gt;[ ] One-shot? Use &lt;code&gt;systemd-run&lt;/code&gt; or &lt;code&gt;at&lt;/code&gt;, not a crontab line&lt;/li&gt;
&lt;li&gt;[ ] Output redirected to a log file with &lt;code&gt;&amp;gt;&amp;gt; job.log 2&amp;gt;&amp;amp;1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Heartbeat ping for anything production-critical&lt;/li&gt;
&lt;li&gt;[ ] Lockfile guard if the job runs frequently or takes variable time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cron is simple enough that none of these traps are obvious until you've hit them. Now you have the list before that happens.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>bash</category>
      <category>programming</category>
    </item>
    <item>
      <title>My Offline App's Backup Lost Every Photo on a New Phone</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:11:39 +0000</pubDate>
      <link>https://dev.to/astraedus/my-offline-apps-backup-lost-every-photo-on-a-new-phone-3d36</link>
      <guid>https://dev.to/astraedus/my-offline-apps-backup-lost-every-photo-on-a-new-phone-3d36</guid>
      <description>&lt;p&gt;I shipped a "Backup your data" button for an offline mood tracker. It exported every entry to a JSON file. Users could save it, move phones, import it, done.&lt;/p&gt;

&lt;p&gt;Then I changed the app's Android package id. A new install is a new sandbox. I restored a backup on the fresh install and every photo was gone. The mood entries came back fine. The images attached to them rendered as broken thumbnails.&lt;/p&gt;

&lt;p&gt;The backup was not actually a backup. It was a list of pointers to files that no longer existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why path references quietly fail
&lt;/h2&gt;

&lt;p&gt;The app stores photos on disk and keeps a row in SQLite that points at the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;media_type&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entry_media&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;file_path&lt;/code&gt; is an absolute path like &lt;code&gt;file:///data/user/0/com.example.app/files/entry_media/1717800000000_a1b2c3.jpg&lt;/code&gt;. My v1 export just dumped those rows into JSON. On the SAME install, re-importing works, because the files are still there. The bug hides until the one moment a backup matters: a different device, a reinstall, a restored phone.&lt;/p&gt;

&lt;p&gt;On a new install none of those paths resolve. The import inserts rows pointing at nothing. The user sees broken images and assumes the app ate their memories.&lt;/p&gt;

&lt;p&gt;If your "export" only carries file paths, you don't have a portable backup. You have a backup that works on exactly the machine that does not need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: carry the bytes, not the path
&lt;/h2&gt;

&lt;p&gt;A backup is portable only if it contains the actual data. So the export now reads each photo off disk and embeds it as base64 directly in the JSON.&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;// expo-file-system moved the classic function API to /legacy at SDK 54.&lt;/span&gt;
&lt;span class="c1"&gt;// readAsStringAsync, writeAsStringAsync and EncodingType live there.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-file-system/legacy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readPhotoBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInfoAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt;&lt;span class="p"&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Skipping photo export, source missing: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EncodingType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Base64&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Skipping photo export, unreadable: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that bit me first: on Expo SDK 54 and up, &lt;code&gt;readAsStringAsync&lt;/code&gt; and &lt;code&gt;EncodingType&lt;/code&gt; are not on the default &lt;code&gt;expo-file-system&lt;/code&gt; import anymore. They live at the &lt;code&gt;expo-file-system/legacy&lt;/code&gt; entrypoint (&lt;a href="https://docs.expo.dev/versions/latest/sdk/filesystem-legacy/" rel="noopener noreferrer"&gt;Expo's own docs confirm it&lt;/a&gt;, and there is an &lt;a href="https://github.com/expo/expo/issues/39858" rel="noopener noreferrer"&gt;open issue&lt;/a&gt; for people who hit the missing methods). If your base64 read returns &lt;code&gt;undefined&lt;/code&gt;, check that import before anything else.&lt;/p&gt;

&lt;p&gt;Each photo becomes a small object with the bytes inside it:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ExportPhoto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;media_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// original path, informational only&lt;/span&gt;
  &lt;span class="nl"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// used to name the restored file&lt;/span&gt;
  &lt;span class="nl"&gt;data_base64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the actual image bytes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I bumped the export to &lt;code&gt;version: 3&lt;/code&gt; so the import side can tell embedded backups from the old path-only ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore the files before you touch the database
&lt;/h2&gt;

&lt;p&gt;The import is where the ordering matters. You have to materialize the photos to the new install's media directory FIRST, then run the database transaction that points rows at the new paths.&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeBase64ToMediaDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ensureMediaDir&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;dest&lt;/span&gt; &lt;span class="o"&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;MEDIA_DIR&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nb"&gt;Date&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="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;rand&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;ext&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeAsStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EncodingType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dest&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;Why split it in two phases? Writing tens of base64 images is slow file IO. I do not want that running inside an exclusive SQLite transaction. So the import does all the file writes up front, collects the new local paths, and only then opens the transaction:&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;// Phase 1: write every embedded photo to disk, OUTSIDE the transaction.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;restoredByEntry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;newPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;media_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;}[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;importData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photos&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_base64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;newPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeBase64ToMediaDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;restoredByEntry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entry&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;??=&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;newPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;media_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;media_type&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: one exclusive transaction inserts rows pointing at the NEW paths.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withExclusiveTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="c1"&gt;// ...insert entries, then for each entry insert its restored media rows&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction inserts &lt;code&gt;entry_media&lt;/code&gt; rows that point at the paths I just wrote on THIS device. The original &lt;code&gt;file_path&lt;/code&gt; from the backup never gets used as a real location. It rides along as a debugging reference and nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it survive bad input
&lt;/h2&gt;

&lt;p&gt;Real backups are messy. A user deleted a photo after their last export. A file got truncated. An old v1 backup is the only one they have. None of that should blow up the whole restore.&lt;/p&gt;

&lt;p&gt;The rule: every step fails soft.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A source file that is missing or unreadable on export is skipped with a warning. One gone image never fails the export.&lt;/li&gt;
&lt;li&gt;A base64 write that fails on import is skipped. That entry just loses one photo instead of aborting.&lt;/li&gt;
&lt;li&gt;v1 and v2 backups still import. They keep the old path-reference behavior, best effort, exactly as before. The version number routes them down the legacy branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A backup or restore should degrade one record at a time, never crash on the whole file. The person running an import is often a person whose phone just died. That's the worst moment to throw an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff is real and it is fine
&lt;/h2&gt;

&lt;p&gt;Base64 inflates the payload by about 33 percent. A library of photos turns a small JSON file into a chunky one. I went back and forth on this and landed on: correctness wins. A backup of tens of megabytes is still easy to share or save to a drive. A 4 KB backup that silently loses every image is worthless.&lt;/p&gt;

&lt;p&gt;If size ever becomes a real problem, the fix is a zip container with the images as separate files, not a clever pointer scheme.&lt;/p&gt;

&lt;p&gt;A few edges I did not wave away. Building the export holds every photo's base64 in one JSON string, so a library of hundreds of images is a memory and payload question, not a free lunch. A zip-streamed container is the next step if that ever bites. The restored extension is derived from the original filename and defaults to &lt;code&gt;jpg&lt;/code&gt;, never trusted blindly, and every restored file gets a fresh generated name so paths can never collide on the new device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Test the one path that matters: export on one install, wipe it, import on a clean install. Not a re-import on the same device. That happy path lies to you.&lt;/p&gt;

&lt;p&gt;If your photos, attachments, or any file-backed data do not survive a different machine, your export is a list of broken pointers wearing a backup's clothes.&lt;/p&gt;

&lt;p&gt;This came out of &lt;a href="https://raeduslabs.com/soulsync" rel="noopener noreferrer"&gt;SoulSync&lt;/a&gt;, a mood tracker that keeps everything on the device with no account and no cloud. When the data never leaves the phone, the backup file is the only way out, so it has to be real.&lt;/p&gt;

&lt;p&gt;Carry the bytes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <item>
      <title>EAS Free Build Quota Ran Out. I Shipped a Signed Play AAB on GitHub Actions Instead.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 26 Jun 2026 12:12:30 +0000</pubDate>
      <link>https://dev.to/astraedus/eas-free-build-quota-ran-out-i-shipped-a-signed-play-aab-on-github-actions-instead-3fn</link>
      <guid>https://dev.to/astraedus/eas-free-build-quota-ran-out-i-shipped-a-signed-play-aab-on-github-actions-instead-3fn</guid>
      <description>&lt;p&gt;I was one artifact away from a Google Play launch. A signed &lt;code&gt;.aab&lt;/code&gt;, that was it. Then &lt;code&gt;eas build -p android&lt;/code&gt; returned the line every free-tier Expo dev eventually meets:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You have used your free tier of builds. Your billing cycle resets on the 1st.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The 1st was eleven days away. I tried &lt;code&gt;eas build --local&lt;/code&gt; and my laptop filled its disk halfway through the all-ABI native compile. I was not going to wait eleven days, and I was not going to pay $99/month for a single build.&lt;/p&gt;

&lt;p&gt;So I built the AAB on a free GitHub Actions runner. Clean machine, isolated, zero cost, about 29 minutes. Here is the whole thing, including the one detail that wastes everyone's afternoon: the signing key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why GitHub Actions and not local
&lt;/h2&gt;

&lt;p&gt;EAS is a convenience layer over &lt;code&gt;gradlew&lt;/code&gt;. Nothing about a release bundle requires Expo's servers. With Continuous Native Generation, your &lt;code&gt;android/&lt;/code&gt; folder is disposable. You run &lt;code&gt;expo prebuild&lt;/code&gt; to regenerate it, then &lt;code&gt;gradlew bundleRelease&lt;/code&gt;, the same as any bare React Native app.&lt;/p&gt;

&lt;p&gt;A GitHub free runner gives you a 2-core, 7GB Ubuntu box with the Android SDK already installed. That is enough for a release bundle if you trim two things: the ABIs you compile, and the junk preinstalled on the runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build config that matters
&lt;/h2&gt;

&lt;p&gt;Two deliberate choices keep the build light and safe.&lt;/p&gt;

&lt;p&gt;First, ABIs. Compiling &lt;code&gt;x86&lt;/code&gt;/&lt;code&gt;x86_64&lt;/code&gt; only feeds emulators. Drop them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# android/gradle.properties
&lt;/span&gt;&lt;span class="py"&gt;reactNativeArchitectures&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;arm64-v8a,armeabi-v7a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers every real phone and roughly halves native-compile time and disk use.&lt;/p&gt;

&lt;p&gt;Second, a judgment call: I keep minification OFF for the first production bundle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;android.enableMinifyInReleaseBuilds&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;android.enableShrinkResourcesInReleaseBuilds&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My reason is parity, not fear of R8. The bundle I upload should behave exactly like the APK I already device-verified, so I am not chasing a minification difference during a launch. R8 strips code it believes is dead, and reflection-heavy code without proper &lt;code&gt;-keep&lt;/code&gt; rules can lose classes you actually need at runtime. A blank paywall is the classic symptom, and I hit a version of it once already in &lt;a href="https://dev.to/diven_rastdus_c5af27d68f3/my-superwall-paywall-showed-blank-prices-on-cold-start-two-bugs-one-was-a-default-7kc"&gt;a separate launch saga&lt;/a&gt;. Most paid SDKs ship their own consumer keep-rules, so a default minified build is usually fine. I still prefer to turn R8 on in a &lt;em&gt;later&lt;/em&gt; release, deliberately, with the rules written and tested, rather than on the build I am shipping under launch pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free the disk before you build
&lt;/h2&gt;

&lt;p&gt;The runner ships with .NET, Haskell, and a pile of Docker images you do not need. Reclaim that space first, or the bundle task dies at 95%.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Free up runner disk space&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jlumbroso/free-disk-space@main&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;       &lt;span class="c1"&gt;# we NEED the Android SDK&lt;/span&gt;
    &lt;span class="na"&gt;dotnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;haskell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;large-packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;docker-images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The signing gotcha (read this part twice)
&lt;/h2&gt;

&lt;p&gt;This is where most attempts brick. If your app already exists on a Play track, Google's Play App Signing has locked onto one specific &lt;strong&gt;upload key&lt;/strong&gt;. Google re-signs your bundle with the real app key on their side, but it only accepts an upload signed by that exact upload certificate. Sign with a fresh throwaway keystore and Play rejects the upload with a fingerprint mismatch.&lt;/p&gt;

&lt;p&gt;So you need the same upload key EAS used for your first build. EAS stores it for you. Pull it down once with &lt;code&gt;eas credentials&lt;/code&gt;, then base64-encode it into a GitHub secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; upload-keystore.jks | gh secret &lt;span class="nb"&gt;set &lt;/span&gt;MY_UPLOAD_KEYSTORE_BASE64
&lt;span class="c"&gt;# the three below prompt for the value interactively, paste each when asked&lt;/span&gt;
gh secret &lt;span class="nb"&gt;set &lt;/span&gt;MY_UPLOAD_KEYSTORE_PASSWORD
gh secret &lt;span class="nb"&gt;set &lt;/span&gt;MY_UPLOAD_KEY_ALIAS
gh secret &lt;span class="nb"&gt;set &lt;/span&gt;MY_UPLOAD_KEY_PASSWORD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this keystore far away from any throwaway CI cert you use for sideload debug builds. They are not interchangeable. One is for Play, one is for &lt;code&gt;adb install&lt;/code&gt;, and mixing them up is the second-most-common way this goes wrong.&lt;/p&gt;

&lt;p&gt;In the workflow, decode it at runtime and hand it to Gradle through injected signing properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Decode upload keystore&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "${{ secrets.MY_UPLOAD_KEYSTORE_BASE64 }}" | base64 -d &amp;gt; "$RUNNER_TEMP/upload.keystore"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build signed release AAB&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;KS_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEYSTORE_PASSWORD }}&lt;/span&gt;
    &lt;span class="na"&gt;KEY_ALIAS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEY_ALIAS }}&lt;/span&gt;
    &lt;span class="na"&gt;KEY_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEY_PASSWORD }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;./gradlew bundleRelease \&lt;/span&gt;
      &lt;span class="s"&gt;-Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.keystore" \&lt;/span&gt;
      &lt;span class="s"&gt;-Pandroid.injected.signing.store.password="$KS_PASS" \&lt;/span&gt;
      &lt;span class="s"&gt;-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \&lt;/span&gt;
      &lt;span class="s"&gt;-Pandroid.injected.signing.key.password="$KEY_PASS" \&lt;/span&gt;
      &lt;span class="s"&gt;--no-daemon&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-daemon&lt;/code&gt; matters on a single-shot runner. The Gradle daemon is built for long-lived dev machines, not a container that gets destroyed in 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole workflow, in order
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;android/&lt;/code&gt; is generated and gitignored, you patch it inside the job, not in your repo. &lt;code&gt;gradlew bundleRelease&lt;/code&gt; writes to &lt;code&gt;android/app/build/outputs/bundle/release/&lt;/code&gt;, so the upload step has to point there (or copy out of it first). That path-matching is the line most likely to break a first run, so here is the complete, ordered file rather than fragments to stitch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/build-aab-play.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build Play AAB (production)&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Branch/ref&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Free up runner disk space&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jlumbroso/free-disk-space@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dotnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;haskell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;large-packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;docker-images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;github.event.inputs.ref || 'main'&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;npm&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;temurin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;17&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx expo prebuild --platform android --no-install&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Patch the generated android/ (light, verified-parity)&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
          &lt;span class="s"&gt;GP=android/gradle.properties&lt;/span&gt;
          &lt;span class="s"&gt;echo 'reactNativeArchitectures=arm64-v8a,armeabi-v7a' &amp;gt;&amp;gt; "$GP"&lt;/span&gt;
          &lt;span class="s"&gt;echo 'android.enableMinifyInReleaseBuilds=false'      &amp;gt;&amp;gt; "$GP"&lt;/span&gt;
          &lt;span class="s"&gt;echo 'android.enableShrinkResourcesInReleaseBuilds=false' &amp;gt;&amp;gt; "$GP"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Decode upload keystore&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "${{ secrets.MY_UPLOAD_KEYSTORE_BASE64 }}" | base64 -d &amp;gt; "$RUNNER_TEMP/upload.keystore"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build signed release AAB&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;KS_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEYSTORE_PASSWORD }}&lt;/span&gt;
          &lt;span class="na"&gt;KEY_ALIAS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEY_ALIAS }}&lt;/span&gt;
          &lt;span class="na"&gt;KEY_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_UPLOAD_KEY_PASSWORD }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;./gradlew bundleRelease \&lt;/span&gt;
            &lt;span class="s"&gt;-Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.keystore" \&lt;/span&gt;
            &lt;span class="s"&gt;-Pandroid.injected.signing.store.password="$KS_PASS" \&lt;/span&gt;
            &lt;span class="s"&gt;-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \&lt;/span&gt;
            &lt;span class="s"&gt;-Pandroid.injected.signing.key.password="$KEY_PASS" \&lt;/span&gt;
            &lt;span class="s"&gt;--no-daemon&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload AAB artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-release-aab&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android/app/build/outputs/bundle/release/*.aab&lt;/span&gt;
          &lt;span class="na"&gt;if-no-files-found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of things earn their place here. &lt;code&gt;set -euo pipefail&lt;/code&gt; makes the patch step fail instead of half-applying. &lt;code&gt;if-no-files-found: error&lt;/code&gt; turns a build that produced no bundle into a red run instead of a green one with an empty artifact. And the &lt;code&gt;path&lt;/code&gt; glob points straight at Gradle's real output, so there is nothing to reconcile by hand.&lt;/p&gt;

&lt;p&gt;The one simplification worth naming: appending the three lines with &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; assumes they are not already set in your &lt;code&gt;gradle.properties&lt;/code&gt;. If they are, switch to a &lt;code&gt;sed&lt;/code&gt; replace so you do not end up with two conflicting values. Check your file once and pick the right tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger it and pull the bundle
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh workflow run build-aab-play.yml &lt;span class="nt"&gt;--ref&lt;/span&gt; main
&lt;span class="c"&gt;# grab the run id once it finishes&lt;/span&gt;
gh run list &lt;span class="nt"&gt;--workflow&lt;/span&gt; build-aab-play.yml
gh run download &amp;lt;run-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have a signed &lt;code&gt;.aab&lt;/code&gt; on your laptop, built on hardware that cost you nothing. Validate it before you upload. If you drive the Play Developer API or a CLI wrapper over it, run a bundle validation, push it to your internal track, then smoke-test the universal APK on a real device with &lt;code&gt;bundletool&lt;/code&gt; before you promote to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Managed build services sell convenience, and the free tier is genuinely good until the month you ship twice. When the quota wall hits at the worst possible moment, remember that the build itself is just Gradle. A free CI runner can do it, and the only real trap is signing: use the upload key Play already locked onto, base64 it into a secret, and inject it at build time.&lt;/p&gt;

&lt;p&gt;I keep this workflow permanent now. Every release after the first is one &lt;code&gt;gh workflow run&lt;/code&gt; and one &lt;code&gt;gh run download&lt;/code&gt;, with zero laptop impact and no quota to watch. If you are mid-launch and staring at a reset date, you do not have to wait.&lt;/p&gt;

&lt;p&gt;I write up the unglamorous parts of shipping small apps solo, the bugs and the launch fights, as I hit them. Follow along if that is your kind of thing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>androiddev</category>
      <category>github</category>
    </item>
    <item>
      <title>My Cron Jobs Were Firing 10 Hours Off Their Own Comments</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 24 Jun 2026 22:11:07 +0000</pubDate>
      <link>https://dev.to/astraedus/my-cron-jobs-were-firing-10-hours-off-their-own-comments-150b</link>
      <guid>https://dev.to/astraedus/my-cron-jobs-were-firing-10-hours-off-their-own-comments-150b</guid>
      <description>&lt;p&gt;I scheduled a heavy maintenance job to run Saturday at 6am, while I was asleep and nothing else was using the machine. Weeks later I checked the logs. It had been firing every Saturday at 8pm, right in the middle of my most active hours.&lt;/p&gt;

&lt;p&gt;The job was fine. The schedule was fine. My assumption about what the schedule meant was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comment lied, and cron didn't care
&lt;/h2&gt;

&lt;p&gt;Here is what the crontab line looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Saturday 06:00 UTC -- weekend maintenance, runs while I'm asleep
0 6 * * 6 /home/me/bin/weekend-maintenance.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment says UTC. I wrote it that way because I think in UTC for infrastructure. But that comment is just text. Cron never reads it.&lt;/p&gt;

&lt;p&gt;My machine's local timezone is AEST (UTC+10). I had not set &lt;code&gt;CRON_TZ&lt;/code&gt; anywhere in the crontab. So cron read &lt;code&gt;0 6&lt;/code&gt; as 6am local AEST, not 6am UTC. The two are 10 hours apart.&lt;/p&gt;

&lt;p&gt;Every time-of-day job in that crontab was firing 10 hours away from what its comment claimed. Eight of them. A morning report meant to land before I woke up fired in the evening. A "run this when the machine is quiet" job ran at peak. None of them errored, so nothing flagged it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why cron does this
&lt;/h2&gt;

&lt;p&gt;Cron schedules in the system's local timezone by default. On Linux that is whatever the system clock is set to, or the &lt;code&gt;TZ&lt;/code&gt; the daemon inherited. It is not UTC unless your machine is set to UTC.&lt;/p&gt;

&lt;p&gt;You can confirm your machine's timezone in 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;timedatectl
&lt;span class="c"&gt;#                Local time: Sat 2026-06-20 20:00:13 AEST&lt;/span&gt;
&lt;span class="c"&gt;#            Universal time: Sat 2026-06-20 10:00:13 UTC&lt;/span&gt;
&lt;span class="c"&gt;#                  Time zone: Australia/Brisbane (AEST, +1000)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is the 10 hour gap, right in the output. Local 20:00 is 10:00 UTC.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to detect it on your own boxes
&lt;/h2&gt;

&lt;p&gt;The fastest check is to compare what a job's comment claims against when it actually ran. Cron logs each invocation. On most systems:&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="c"&gt;# Debian / Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;grep &lt;/span&gt;CRON /var/log/syslog | &lt;span class="nb"&gt;grep &lt;/span&gt;weekend-maintenance

&lt;span class="c"&gt;# RHEL / Fedora, or anything on systemd&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-t&lt;/span&gt; CRON &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"14 days ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;weekend-maintenance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the line that gave me away. The comment promised 06:00 UTC. The log disagreed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# crontab comment claims 06:00 UTC. Reality:
Jun 20 20:00:01 box CRON[12491]: (me) CMD (/home/me/bin/weekend-maintenance.sh)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;20:00 local, not 06:00. Line up those timestamps with the hour you THINK the job runs. If they disagree by exactly your UTC offset, you have found it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop writing comments you aren't enforcing
&lt;/h2&gt;

&lt;p&gt;There are two honest ways out, and which one you pick depends on the job.&lt;/p&gt;

&lt;p&gt;For jobs whose intent is local ("run while I'm asleep", "send before the workday"), write the schedule in local time and label it as local. No surprise, no conversion in your head:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Saturday 06:00 AEST (machine-local) -- weekend maintenance
0 6 * * 6 /home/me/bin/weekend-maintenance.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For jobs whose intent is a real fixed instant (a market-hours alert, a cross-region batch that must line up with UTC), set &lt;code&gt;CRON_TZ&lt;/code&gt; explicitly at the top of the block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRON_TZ=UTC
# Now 0 6 genuinely means 06:00 UTC, whatever the machine's clock says
0 6 * * 6 /home/me/bin/utc-batch.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CRON_TZ&lt;/code&gt; overrides the timezone for every entry below it, until the next &lt;code&gt;CRON_TZ&lt;/code&gt;. The standard cron daemons on mainstream Linux (Vixie and cronie) support it. It is not POSIX, so check your daemon if you run something exotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that bit me twice: CRON_TZ is not TZ
&lt;/h2&gt;

&lt;p&gt;One job genuinely needed US market hours, so I reached for &lt;code&gt;CRON_TZ=America/New_York&lt;/code&gt;. That is correct, and naming a region handles US daylight saving automatically, which is the whole reason to use a region name instead of a fixed offset.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;CRON_TZ&lt;/code&gt; only controls WHEN the job fires. It does not set the timezone inside your script. The process still inherits the system &lt;code&gt;TZ&lt;/code&gt;. If your script formats a timestamp or does date math, it uses the machine's timezone, not the one you scheduled in. If you need both, set both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRON_TZ=America/New_York
0 9 * * 1-5  TZ=America/New_York /home/me/bin/market-open.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That inline &lt;code&gt;TZ=&lt;/code&gt; works because cron runs each job line through a shell, so a leading &lt;code&gt;VAR=value&lt;/code&gt; becomes an environment assignment for the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Daylight saving still wants to ruin your day
&lt;/h2&gt;

&lt;p&gt;Naming a region (&lt;code&gt;America/New_York&lt;/code&gt;) instead of a fixed offset means cron tracks DST for you. Good. But DST itself has two sharp edges, and &lt;code&gt;CRON_TZ&lt;/code&gt; does not file them down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On spring-forward, the 02:00 to 03:00 hour does not exist. A job scheduled at 02:30 just does not run that day.&lt;/li&gt;
&lt;li&gt;On fall-back, that hour happens twice. A job inside it can run twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The boring fix is to keep anything important out of the 01:00 to 03:00 window. Schedule at 04:00 or later and DST never touches you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the Saturday job
&lt;/h2&gt;

&lt;p&gt;The fix for the job that started all this was the boring one. I rewrote the comment to match the mechanism and left the schedule alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Saturday 06:00 AEST (machine-local) -- weekend maintenance
0 6 * * 6 /home/me/bin/weekend-maintenance.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next Saturday, &lt;code&gt;journalctl -t CRON&lt;/code&gt; showed it firing at 06:00 AEST. Machine quiet, me asleep, finally doing what the comment always claimed.&lt;/p&gt;

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

&lt;p&gt;The bug was not cron. Cron did exactly what it always does. The bug was that I trusted a comment to enforce behavior it had no power over.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;# runs at 09:00 UTC&lt;/code&gt; comment is documentation. It is the weakest thing in the system, because nothing checks it against reality. The schedule is the enforcement. When the two drift apart, the schedule wins, and you do not get an error. You get ten hours of silent wrongness.&lt;/p&gt;

&lt;p&gt;The rule I follow now is small. Write each schedule in the timezone you actually mean. Set &lt;code&gt;CRON_TZ&lt;/code&gt; (and &lt;code&gt;TZ&lt;/code&gt;) when correctness depends on it. And never let "the server is probably UTC" be the load-bearing assumption.&lt;/p&gt;

&lt;p&gt;Go run &lt;code&gt;timedatectl&lt;/code&gt; on your boxes, then read your crontab comments back as claims instead of facts. You might have a job that has been quietly firing at the wrong hour for months. I did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>cron</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>My Superwall Paywall Showed Blank Prices on Cold Start. Two Bugs, One Was a Default.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 22 Jun 2026 12:10:48 +0000</pubDate>
      <link>https://dev.to/astraedus/my-superwall-paywall-showed-blank-prices-on-cold-start-two-bugs-one-was-a-default-7kc</link>
      <guid>https://dev.to/astraedus/my-superwall-paywall-showed-blank-prices-on-cold-start-two-bugs-one-was-a-default-7kc</guid>
      <description>&lt;p&gt;My paywall presented perfectly. Right design, right layout, right call-to-action. The only problem: the subscription prices were blank.&lt;/p&gt;

&lt;p&gt;Weekly, monthly, annual: each tier showed its period label ("/wk", "/mo", "/yr") and an empty space where the price should be. Here is the strange part. RevenueCat, running side by side in the same app, had already fetched every real price a second earlier. The lifetime tier, sitting in the same paywall, rendered its price fine ($184.99). So the prices existed. Superwall just refused to draw three of them.&lt;/p&gt;

&lt;p&gt;That mismatch took nine rounds of debugging to crack. Here is what was actually wrong, so you can skip the nine rounds.&lt;/p&gt;

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

&lt;p&gt;The app is an Expo / React Native app. The paywall stack is &lt;code&gt;expo-superwall&lt;/code&gt; for presentation and A/B testing, with RevenueCat as the single source of truth for entitlements. They talk through Superwall's recommended &lt;code&gt;CustomPurchaseControllerProvider&lt;/code&gt;, so a Superwall purchase routes through &lt;code&gt;react-native-purchases&lt;/code&gt; and fires RevenueCat's normal listener.&lt;/p&gt;

&lt;p&gt;This is a common combination. Superwall owns the paywall UI, RevenueCat owns "who is paying". If you run it, you can hit both of the bugs below.&lt;/p&gt;

&lt;p&gt;One Superwall detail matters for the rest of this story. Superwall renders the paywall in a webview and fills in values like &lt;code&gt;{{ products.primary.price }}&lt;/code&gt; from a product-variable map it builds from the loaded store products. If that map is empty, the template renders an empty string. An empty string is your blank price. So "blank prices" really means "Superwall built an empty product-variable map", and the whole hunt is about why that map came up empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I was blind for so long
&lt;/h2&gt;

&lt;p&gt;The first real unlock was not a fix. It was visibility.&lt;/p&gt;

&lt;p&gt;Superwall's SDK logs are invisible at the default log level. In my app only RevenueCat logged anything, so logcat looked like Superwall was barely running. "No Superwall product query in the logs" felt like proof of a dead SDK. It was not. The SDK was busy, just silent.&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SuperwallProvider&lt;/span&gt;
  &lt;span class="nx"&gt;apiKeys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;apiKeys&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&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="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;level: 'debug'&lt;/code&gt; and &lt;code&gt;scopes: ['all']&lt;/code&gt;, the productsManager, storeKit, and paywallPresentation lines all appeared. Now I could see Superwall building its product variables and coming up empty, right next to RevenueCat fetching the same prices without trouble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Superwall] [productsManager] ERROR: Billing client not ready
[RevenueCat] Retrieved productDetailsList: annual ... $85.99 ... freeTrial:7d
[Superwall] [paywallPresentation] productVariables: null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two billing clients, one ready and one not, in the same process. Lesson one: before you theorize, turn the logs on. A quiet SDK is not an idle SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the entitlement status was stuck on Unknown
&lt;/h2&gt;

&lt;p&gt;With a custom purchase controller, you are responsible for telling Superwall the subscription status. Superwall defaults that status to &lt;code&gt;SubscriptionStatus.Unknown&lt;/code&gt; at configure time. Its presentation pipeline has an operator, &lt;code&gt;WaitForSubsStatusAndConfig&lt;/code&gt;, that blocks until the status is anything other than Unknown. The SDK's own error text says it plainly: if you use a custom purchase controller, set the entitlement status on time.&lt;/p&gt;

&lt;p&gt;My sync code did call RevenueCat's &lt;code&gt;getCustomerInfo()&lt;/code&gt; once at startup and pushed the result into Superwall. The problem was timing. &lt;code&gt;Purchases.configure&lt;/code&gt; in my app is gated behind auth, but the Superwall provider mounts outside the auth provider. So the initial &lt;code&gt;getCustomerInfo()&lt;/code&gt; could run before RevenueCat was configured, throw, and get swallowed. The change listener only fires on a change, so the status never moved off Unknown.&lt;/p&gt;

&lt;p&gt;While the status sat at Unknown, the present pipeline stalled and the product-variable map came out empty. Empty variables render as blank prices in the webview. That matched exactly what the debug logs showed.&lt;/p&gt;

&lt;p&gt;The fix was to seed a concrete status before any paywall can present, retrying until RevenueCat is actually configured:&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;// retries getCustomerInfo() until RC is configured (bounded ~6s),&lt;/span&gt;
&lt;span class="c1"&gt;// then pushes a concrete ACTIVE / INACTIVE into Superwall&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;seedInitialSubscriptionStatus&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;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCustomerInfoWhenReady&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// bounded retry loop&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hasProEntitlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;Superwall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSubscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;isActive&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;activeStatus&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inactiveStatus&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;If RevenueCat never configures within the bound, the seed falls back to INACTIVE, so a non-paying user still sees the paywall (the safe default), and the change listener owns every later transition. That was the real fix. The status now reaches Superwall as a known value before the first present, so the pipeline stops stalling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: a default that flips between platforms
&lt;/h2&gt;

&lt;p&gt;After the status fix, prices rendered. Sometimes. If I let the app sit for the better part of a minute before opening the paywall, every price showed. Open the paywall fast after a cold launch and it went blank again.&lt;/p&gt;

&lt;p&gt;The debug logs explained it. On a fast view, Superwall computed the paywall's product variables before its own store manager had loaded the products. No loaded products meant a null variable map, which meant blank prices. When I waited, RevenueCat had incidentally warmed the shared Google Play product cache by then, so Superwall's later fetch was a cache hit. My "fix" was riding on luck and a warm cache.&lt;/p&gt;

&lt;p&gt;The root cause is a default value. In &lt;code&gt;expo-superwall@1.1.5&lt;/code&gt;, the default options ship &lt;code&gt;shouldPreload: false&lt;/code&gt;. You can read it straight from the package:&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;// node_modules/expo-superwall/build/src/SuperwallOptions.js:15&lt;/span&gt;
&lt;span class="nx"&gt;DefaultSuperwallOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paywalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shouldPreload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// merge in useSuperwall: { ...DefaultSuperwallOptions.paywalls, ...yourOptions.paywalls }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native &lt;code&gt;superwall-android&lt;/code&gt; SDK preloads paywalls by default, so the expo wrapper quietly flips the behavior. If you pass &lt;code&gt;options&lt;/code&gt; without a &lt;code&gt;paywalls&lt;/code&gt; key, the merge keeps the &lt;code&gt;false&lt;/code&gt;, preload stays off, and products only load at trigger time. The fix is one line, set explicitly:&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="nx"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
  &lt;span class="na"&gt;paywalls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shouldPreload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With preload on, Superwall warms its own products and builds the paywall's variables at launch, independent of RevenueCat's timing. The cold launch logs now tell the opposite story, before any trigger fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Superwall] config_attributes should_preload=true
[Superwall] [paywallPreload] paywallPreload_start
[Superwall] [paywallPreload] paywallPreload_complete
[Superwall] [paywallPresentation] productVariables: { weekly, monthly, annual }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prices render every time now, fast cold start or slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The red herring
&lt;/h2&gt;

&lt;p&gt;For two rounds I chased a webview error: &lt;code&gt;store/document/undefined - Code: 404&lt;/code&gt;. It looked like the smoking gun. It was not. That request fires even when prices render correctly. Chasing it cost me real time.&lt;/p&gt;

&lt;p&gt;When you debug an SDK you do not own, separate the symptoms that correlate with the bug from the noise that is always there. Reproduce the success state and check whether your "smoking gun" is still firing. If it is, it is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why lifetime rendered and subscriptions did not
&lt;/h2&gt;

&lt;p&gt;The detail that nagged me the whole time: the lifetime tier always showed its price. Lifetime is a one-time purchase with no base plan or offer, so it follows a simpler resolution path. The subscriptions go through the product-variable substitution that both bugs broke. One blank tier next to one working tier is a strong hint that the problem is in the per-product variable path, not the network or the store account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Three things to carry into your own Superwall plus RevenueCat integration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on &lt;code&gt;logging: { level: 'debug', scopes: ['all'] }&lt;/code&gt; first. The SDK is silent by default, and you cannot fix what you cannot see.&lt;/li&gt;
&lt;li&gt;With a custom purchase controller, seed a concrete subscription status before any paywall presents. An Unknown status short-circuits product loading.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;paywalls: { shouldPreload: true }&lt;/code&gt; explicitly. The expo wrapper defaults it to false, against the native default, and that single value decides whether your prices survive a fast cold start.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I am building a mobile app solo and writing up the real bugs as I hit them. If you want more of these field notes, follow along.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Our Tests Were Green. The Feature Had Never Worked.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sun, 21 Jun 2026 12:14:02 +0000</pubDate>
      <link>https://dev.to/astraedus/our-tests-were-green-the-feature-had-never-worked-2gpo</link>
      <guid>https://dev.to/astraedus/our-tests-were-green-the-feature-had-never-worked-2gpo</guid>
      <description>&lt;p&gt;I was doing a UI cleanup pass on our astrology companion app. One card said "Recurring themes are coming soon" in a PRO section. I restyled it to match the new design and moved on.&lt;/p&gt;

&lt;p&gt;Our tech lead caught it an hour later.&lt;/p&gt;

&lt;p&gt;The feature wasn't "coming soon." It was built. Three months ago. The component existed, the data pipeline existed, the edge function computed the themes and returned them. But a parent component never passed the &lt;code&gt;themes&lt;/code&gt; prop. One unwired seam. The card had been silently dead for every paying user since launch.&lt;/p&gt;

&lt;p&gt;The worst part: &lt;code&gt;tsc&lt;/code&gt; was clean. Unit tests were green. There was no error to find.&lt;/p&gt;

&lt;p&gt;Here's what the broken wiring looked like:&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;// What shipped -- tsc passes, component tests pass, no warnings&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MirrorCard&lt;/span&gt; &lt;span class="na"&gt;isPro&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;          &lt;span class="c1"&gt;// themes prop optional, defaults to []&lt;/span&gt;

&lt;span class="c1"&gt;// What a paying user needed&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MirrorCard&lt;/span&gt; &lt;span class="na"&gt;isPro&lt;/span&gt; &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;insights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recurringThemes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One token. A green test suite and a one-token-wide hole.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem is never the piece, it's the seam
&lt;/h2&gt;

&lt;p&gt;Every unit test verifies a piece in isolation. Pass in these inputs, expect these outputs. That's the right thing to test.&lt;/p&gt;

&lt;p&gt;But user-facing features aren't pieces. They're chains: edge function computes data, API response carries it, parent receives it, parent passes it to the component, component renders it, button routes somewhere real. A test can verify every link in that chain individually while the chain itself is broken.&lt;/p&gt;

&lt;p&gt;The gap is the seam. And seams are invisible to your test suite.&lt;/p&gt;

&lt;p&gt;After we found the themes card, we ran a completeness audit and found the same pattern in three more places:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live transit data that never rendered.&lt;/strong&gt; The edge function computed the day's planetary transits correctly. Tests passed. But the response body didn't include the &lt;code&gt;transits&lt;/code&gt; field, so the Today screen's chips never had data to render. Empty chips, no error, zero indication anything was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A "PRO unlock" that didn't unlock anything.&lt;/strong&gt; There was a line of text saying "Unlock with PRO" on a life-area screen. It looked interactive. It was a &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; component with no &lt;code&gt;onPress&lt;/code&gt; handler. The paywall never opened. A user could tap it a hundred times and get nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A share card no one could reach.&lt;/strong&gt; We'd built a synastry share card, full design, full animation. It was never mounted anywhere in the navigation tree. The only way to know it existed was to read the source.&lt;/p&gt;

&lt;p&gt;Four different failure modes. Same root cause: the seam was missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why your tools can't see this
&lt;/h2&gt;

&lt;p&gt;TypeScript checks types within a file, not whether a prop gets passed between files. It knows &lt;code&gt;&amp;lt;MirrorCard&amp;gt;&lt;/code&gt; accepts a &lt;code&gt;themes&lt;/code&gt; prop of a certain shape. It doesn't know whether any parent ever supplies it.&lt;/p&gt;

&lt;p&gt;Jest tests a component's behavior given a prop. Not whether that prop ever arrives in production.&lt;/p&gt;

&lt;p&gt;ESLint catches unused imports, not unused components. A component that exports correctly but is mounted nowhere will pass every lint rule.&lt;/p&gt;

&lt;p&gt;The only thing that catches "the chain is broken" is actually walking the chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seam-tracing habit
&lt;/h2&gt;

&lt;p&gt;When you see any of these on a shipping surface, treat it as a bug to investigate, not text to restyle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Coming soon"&lt;/li&gt;
&lt;li&gt;"Feature locked" / "Upgrade to unlock"&lt;/li&gt;
&lt;li&gt;A TODO comment in a rendered string&lt;/li&gt;
&lt;li&gt;A placeholder UI that matches the app's design language perfectly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question isn't "is this intentional?" The question is: can a user actually get what this surface promises? Walk the chain from the user's tap to the data being delivered. If any link is missing, that's the bug.&lt;/p&gt;

&lt;p&gt;For each feature seam, I now check four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the parent pass the data?&lt;/strong&gt; Look at every call site of the component. Is the relevant prop actually supplied?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the API response include the field?&lt;/strong&gt; Not "does the function compute it." Does the response body carry it back to the client?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the component actually mounted?&lt;/strong&gt; Search your navigation tree and any conditional renders. Is there a path a user can actually reach this component?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the CTA route somewhere real?&lt;/strong&gt; That "Unlock with PRO" text from earlier was a &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; with no &lt;code&gt;onPress&lt;/code&gt;. Check every tappable element traces to an actual handler. &lt;code&gt;console.log&lt;/code&gt; and empty &lt;code&gt;onPress={() =&amp;gt; {}}&lt;/code&gt; are not handlers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This sounds obvious stated plainly. In practice, when you're refactoring UI across 40 screens, you restyle what's there and move on. The seam audit is a separate pass.&lt;/p&gt;




&lt;h2&gt;
  
  
  After any broad refactor: run a completeness audit
&lt;/h2&gt;

&lt;p&gt;We added a step to our post-refactor checklist:&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="c"&gt;# Find any "coming soon" or placeholder text in the shipping codebase&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rni&lt;/span&gt; &lt;span class="s2"&gt;"coming soon&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;todo&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;placeholder"&lt;/span&gt; src/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.tsx"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.jsx"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every hit, ask: is this intentional? If no, trace the seam.&lt;/p&gt;

&lt;p&gt;For the highest-stakes paths (paywall CTAs, PRO feature gates, any screen a paying user specifically navigated to), we live-smoke the deployed build. Not the dev build. The deployed one. Because "code exists" in a dev environment and "feature works" in production are different claims.&lt;/p&gt;

&lt;p&gt;The flow for a PRO gate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log in as a subscribed user on the deployed build&lt;/li&gt;
&lt;li&gt;Navigate to the feature&lt;/li&gt;
&lt;li&gt;Try the thing the UI promises&lt;/li&gt;
&lt;li&gt;Verify you got actual content, not an empty state or a silent dead end&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This takes 15 minutes for a full suite of critical paths. It catches things that would otherwise reach users and never generate an error log. Silent disappointment is the outcome.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing about silent failures
&lt;/h2&gt;

&lt;p&gt;Errors are easy. Your monitoring catches them, your users report them, your CI catches them in tests.&lt;/p&gt;

&lt;p&gt;Silent failures are harder. The app doesn't crash. No error is logged. The user taps a button that does nothing, or sees an empty state they assume is "just how it works," or pays for a feature they can never access. They churn. You never know why.&lt;/p&gt;

&lt;p&gt;The seam audit exists specifically to catch that class of failure before it reaches users.&lt;/p&gt;

&lt;p&gt;One unwired prop. That's all it took to make a paid feature invisible to every subscriber we had.&lt;/p&gt;




&lt;p&gt;If you're building on Expo + Supabase, the seam pattern shows up most reliably at the edge function boundary. The function computes correctly. What it returns to the client is a different question. Always verify the response body, not just the function logic.&lt;/p&gt;

&lt;p&gt;What's the dumbest seam bug you've shipped? Mine was a &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; that looked like a button for three months.&lt;/p&gt;

&lt;p&gt;We write about building real apps (and the things that go wrong) at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I built a free, open-source Daylio alternative instead of paying a subscription</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 19 Jun 2026 23:34:41 +0000</pubDate>
      <link>https://dev.to/astraedus/i-built-a-free-open-source-daylio-alternative-instead-of-paying-a-subscription-5gap</link>
      <guid>https://dev.to/astraedus/i-built-a-free-open-source-daylio-alternative-instead-of-paying-a-subscription-5gap</guid>
      <description>&lt;p&gt;I tried to start tracking my mood properly this year. The advice everyone gives is the same: log it daily, look at the patterns, adjust. Fine. So I went looking for the app.&lt;/p&gt;

&lt;p&gt;Daylio is the one everyone points to, and it's genuinely good. Clean, fast, the design is years ahead of most of the category. I used it for a couple of weeks. Then I went to look at the actual stats, the part that's the whole point, and hit a wall.&lt;/p&gt;

&lt;p&gt;The interesting graphs are premium. Mood-by-weekday, the activity correlations, the longer trends. That's the data that tells you something. And it sits behind a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Daylio actually costs
&lt;/h2&gt;

&lt;p&gt;I want to be fair here, so I checked the current numbers rather than going off memory. Daylio has a real free tier, and it works. But Daylio Premium runs &lt;strong&gt;$4.99 a month or $35.99 a year&lt;/strong&gt;, with a 7-day trial. (They run discounts on the annual plan fairly often.)&lt;/p&gt;

&lt;p&gt;The free version is not crippled, but the things it locks are the things I cared about. When you're on free, you lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advanced stats&lt;/li&gt;
&lt;li&gt;Moods and goals past a free limit&lt;/li&gt;
&lt;li&gt;Reminders past a free limit&lt;/li&gt;
&lt;li&gt;Automatic backups&lt;/li&gt;
&lt;li&gt;PDF export&lt;/li&gt;
&lt;li&gt;Premium themes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the deal is: the tracking is free, the understanding is paid. For a tool whose entire value is helping you see your own patterns, paywalling the patterns felt backwards to me. I wrote more about that specific objection in &lt;a href="https://astraedus.dev/blog/mood-tracker-no-subscription/" rel="noopener noreferrer"&gt;why mental-health tracking behind a subscription is backwards&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To Daylio's credit on privacy: they don't send your entries to their servers, and backups go to your own Google Drive or iCloud over encrypted channels. That part is good and I'm not going to pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built SoulSync
&lt;/h2&gt;

&lt;p&gt;I'm a developer, and I had a stubborn opinion, which is a dangerous combination. I built &lt;a href="https://astraedus.dev/soulsync/" rel="noopener noreferrer"&gt;SoulSync&lt;/a&gt;, a mood tracker and journal with two rules: nothing behind a paywall, and nothing leaves your device.&lt;/p&gt;

&lt;p&gt;It's React Native and Expo, TypeScript in strict mode, 333 passing tests, and it's GPL-3.0. Open source from the start. The code that holds your most private data is public, so you can read it instead of trusting a sentence on a marketing page.&lt;/p&gt;

&lt;p&gt;Every entry is stored in a SQLite database on your phone. Photos too. There is no account to create, no server to connect to, no cloud bucket sitting somewhere with your feelings in it. I go deeper on the architecture in &lt;a href="https://astraedus.dev/blog/private-offline-mood-tracker/" rel="noopener noreferrer"&gt;a mood tracker shouldn't need an account or the cloud&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest comparison
&lt;/h2&gt;

&lt;p&gt;Here's the side by side. I've kept it to things I can actually back up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SoulSync&lt;/th&gt;
&lt;th&gt;Daylio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;Free (open source)&lt;/td&gt;
&lt;td&gt;Free tier; Premium $4.99/mo or $35.99/yr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advanced stats&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activity correlation&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data export&lt;/td&gt;
&lt;td&gt;JSON, included&lt;/td&gt;
&lt;td&gt;PDF export is Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Themes&lt;/td&gt;
&lt;td&gt;5, all free&lt;/td&gt;
&lt;td&gt;Some themes are Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entries stored on device&lt;/td&gt;
&lt;td&gt;Yes (SQLite)&lt;/td&gt;
&lt;td&gt;Yes (cloud backup is optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;Yes, GPL-3.0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;Android (free APK)&lt;/td&gt;
&lt;td&gt;Android and iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Daylio wins on polish and platform reach. It's been worked on for years by a team, and it shows. If you're on iPhone, it's your option and SoulSync isn't, yet.&lt;/p&gt;

&lt;p&gt;SoulSync wins on the two things I built it for: you pay nothing for the full feature set, and the code is open so the privacy claim is checkable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What open source plus local-first actually buys you
&lt;/h2&gt;

&lt;p&gt;Two phrases get thrown around a lot. Here's what they mean in practice, not in pitch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local-first&lt;/strong&gt; means the app works with no network, the data is a file you own, and there's no account that can be locked or breached. If my server vanished tomorrow, your SoulSync history wouldn't even notice. There is no server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source&lt;/strong&gt; means the privacy story isn't a promise, it's source code. You can read exactly where your data goes, which is nowhere, and so can anyone else. It also means the app can't quietly add tracking in a future update without it being visible in the commits.&lt;/p&gt;

&lt;p&gt;And it means free without a catch. There's no premium tier waiting to be unlocked because there's no business model that depends on one. The whole app is the free version because there is no other version.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;SoulSync is Android-only for now. &lt;a href="https://github.com/Antimatter543/mood-tracker/releases/latest" rel="noopener noreferrer"&gt;Download the APK from the latest GitHub release&lt;/a&gt; and log your first entry. It stays on your phone.&lt;/p&gt;

&lt;p&gt;If you find a bug or want a feature, the repo is right there: &lt;a href="https://github.com/Antimatter543/mood-tracker" rel="noopener noreferrer"&gt;github.com/Antimatter543/mood-tracker&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>reactnative</category>
      <category>android</category>
      <category>privacy</category>
    </item>
    <item>
      <title>My Expo Device Tests Could Break Without CI Noticing. Here Is the Fix.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:09:29 +0000</pubDate>
      <link>https://dev.to/astraedus/my-expo-device-tests-could-break-without-ci-noticing-here-is-the-fix-m48</link>
      <guid>https://dev.to/astraedus/my-expo-device-tests-could-break-without-ci-noticing-here-is-the-fix-m48</guid>
      <description>&lt;p&gt;My on-device QA used to be an AI tapping my app by pixel coordinates. One regression walk logged 83 raw coordinate taps. It was slow, it was flaky, and when it failed I could never tell if the app broke or the tap landed two pixels off a button.&lt;/p&gt;

&lt;p&gt;This is the story of replacing that. The same six launch-blocker flows now run in about 90 seconds, with no model in the loop, plus a Jest guard that makes the whole thing unregressable. The app is Origo, an Expo SDK 56 (React Native 0.85) astrology app I am shipping to the Play Store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the AI fell back to pixel coordinates
&lt;/h2&gt;

&lt;p&gt;I was driving the Pixel 3 with an accessibility-tree tool. Point it at an element, it reads the tree, finds the node, taps the node. On a native app this is fast and stable, because native widgets populate the accessibility tree for free.&lt;/p&gt;

&lt;p&gt;React Native does not. A &lt;code&gt;&amp;lt;Pressable&amp;gt;&lt;/code&gt; shows up as an anonymous view unless you give it a &lt;code&gt;testID&lt;/code&gt;. My app had nine testIDs across 164 files. So the tree-matching found almost nothing, and the tool degraded to its fallback: tap by screen percentage. That is the 83 taps. Every one is a guess at where a button rendered, and every screen size or layout tweak silently invalidates it.&lt;/p&gt;

&lt;p&gt;The lesson is boring and load-bearing: &lt;strong&gt;an RN app is only as queryable as its testIDs.&lt;/strong&gt; Fixing the QA speed was downstream of fixing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: testIDs on the flows that matter
&lt;/h2&gt;

&lt;p&gt;I did not testID-everything. I picked the six flows that, if broken, block launch. Those are sign in, upgrade-to-pro opening the paywall, a PRO reading hitting the paywall, editing birth data, synastry (the relationship-compatibility screen) add-person refreshing the list, and sign out. Then I added stable testIDs to exactly the elements those flows touch. That took the count to 99 testIDs across 30 files, each one paying for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: encode the flows in Maestro, self-contained
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://maestro.mobile.dev" rel="noopener noreferrer"&gt;Maestro&lt;/a&gt; (2.6.0) runs YAML flows against a real device. The first version chained flows with &lt;code&gt;clearState&lt;/code&gt; and &lt;code&gt;back&lt;/code&gt;, and it was brittle: one flow would exit on the wrong screen and the next would start from a bad state.&lt;/p&gt;

&lt;p&gt;The fix was to make every flow state-independent. Flow 1 clears state and tests the welcome to sign-in path itself. Flows 2 through 5 start with a plain &lt;code&gt;launchApp&lt;/code&gt;. No state clear. On this app that preserves the signed-in Supabase session and drops you on the Today tab. So each flow re-navigates from a known state and never depends on the previous flow's exit screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FLOW 2 - Upgrade-to-Pro opens the paywall (self-contained)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;launchApp&lt;/span&gt;                      &lt;span class="c1"&gt;# preserves session, lands on Today&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;point&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;90%,92%"&lt;/span&gt;             &lt;span class="c1"&gt;# You tab&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade-cta"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assertVisible&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Restore"&lt;/span&gt;       &lt;span class="c1"&gt;# substring matches both paywall variants&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;assertVisible: "Restore"&lt;/code&gt; is deliberate. Two different paywalls can render depending on whether RevenueCat's offering loaded at runtime, and "Restore" is a substring present in both. Assert the thing that is true in every valid state, not the exact string of one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: an on-device suite can break with zero CI signal
&lt;/h2&gt;

&lt;p&gt;Here is the part that bit me and that I have not seen written down.&lt;/p&gt;

&lt;p&gt;Those testIDs only take effect after a new build. And a Maestro failure only surfaces on-device, when someone bothers to run the suite. So if I rename &lt;code&gt;upgrade-cta&lt;/code&gt; to &lt;code&gt;upgrade-button&lt;/code&gt; during a refactor, nothing fails. Tests pass. CI is green. The regression suite is quietly dead, and I find out the next time I happen to plug in the phone.&lt;/p&gt;

&lt;p&gt;A device suite that can rot silently is worse than no suite, because it tells you that you are covered when you are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a Jest contract test makes the testIDs unregressable
&lt;/h2&gt;

&lt;p&gt;So I pushed the guarantee down to the unit level, where it runs on every commit with no device. A plain Jest test asserts two things: every testID the Maestro suite depends on still exists in the source file that renders it, and the Maestro YAML references exactly that set and no other.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onboarding-sign-in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app/onboarding/index.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upgrade-cta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/screens/you/SubscriptionCard.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paywall-purchase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/screens/onboarding/CustomPaywall.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ... one entry per testID the suite drives&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;))(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;testID "%s" is declared in %s&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="nx"&gt;testId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&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;contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;srcPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`testID="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;testId&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the Maestro flow references only contract testIDs (no drift)&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="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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.maestro/origo-regression.yaml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;referenced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;id:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*"&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&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;contract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TESTID_SOURCES&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referenced&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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="nf"&gt;toEqual&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;It checks the source text, not the rendered output, on purpose. It is the literal &lt;code&gt;testID="..."&lt;/code&gt; string the device tooling matches, so I want to assert that exact string survives, independent of render internals or RN mocks. Rename a testID and forget the YAML, the contract test goes red on the next commit. Add a flow that needs a new testID, add the pair to the map and the guard enforces it forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: run the same flows against the release APK
&lt;/h2&gt;

&lt;p&gt;The last gap: I was testing the dev client over Metro, not the artifact users install. So the runner got a second mode. &lt;code&gt;SMOKE_TARGET=installed&lt;/code&gt; skips all the Metro and dev-launcher setup and drives a plain installed release APK. The flows' Metro-connect step is a conditional subflow, so it is a no-op there, and the exact same six flows validate the CI build.&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;EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="nv"&gt;PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="nv"&gt;SMOKE_TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;installed &lt;span class="se"&gt;\&lt;/span&gt;
  ./scripts/device-smoke.sh .maestro/origo-regression.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of gotchas worth saving you the time: Maestro's GraalJS runtime has no &lt;code&gt;Thread.sleep&lt;/code&gt;, so timed waits go through a small busy-wait helper. And tab-bar taps use a screen percentage rather than a testID, because on the Pixel 3 the tab testID bounds bleed into the Android nav bar and the tap misses. That is the one place coordinates are correct, because the position is fixed and the elements are not individually addressable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Speed was never the real problem. The real problem was determinism. Three changes fixed it: make the app queryable with testIDs on the flows that matter, encode those flows so they do not depend on each other, and then guard the fragile contract (testIDs that only exist after a build, failures that only show on-device) with a unit test that runs on every commit.&lt;/p&gt;

&lt;p&gt;If you take one thing: an on-device test suite that can break without turning anything red is a liability. Find the part that can drift silently and pin it down a layer, where the cost of breaking it is a failed CI run, not a missed regression in production.&lt;/p&gt;

&lt;p&gt;I am building Origo in the open. Next up: the paywall that renders two different ways depending on whether RevenueCat's offering loaded, and how I stopped guessing which one a user sees. Follow if that is your kind of problem.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>testing</category>
      <category>mobile</category>
    </item>
    <item>
      <title>4 Ways RevenueCat Silently Denies Paying Users Their Entitlements</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Tue, 16 Jun 2026 12:10:50 +0000</pubDate>
      <link>https://dev.to/astraedus/4-ways-revenuecat-silently-denies-paying-users-their-entitlements-3d6e</link>
      <guid>https://dev.to/astraedus/4-ways-revenuecat-silently-denies-paying-users-their-entitlements-3d6e</guid>
      <description>&lt;p&gt;Your store credentials are valid. RevenueCat accepted the service account. The SDK initialized without throwing. And your paying users are still seeing the free tier.&lt;/p&gt;

&lt;p&gt;This is the second layer of the RevenueCat wiring problem. The one that doesn't produce an error. The first layer (invalid credentials, service account propagation delays) has been written about. This one hasn't. These are four bugs I found building Origo, an AI astrology app on Expo + RevenueCat + Supabase. All four were silent. All four passed tests. All four denied real users real value.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. &lt;code&gt;Purchases.logIn()&lt;/code&gt; was never called
&lt;/h2&gt;

&lt;p&gt;This was the most expensive one. Three days of confused billing debugging before I found it.&lt;/p&gt;

&lt;p&gt;Every purchase made before you call &lt;code&gt;Purchases.logIn(userId)&lt;/code&gt; registers under an anonymous RC identity: &lt;code&gt;$RCAnonymousID:some-uuid&lt;/code&gt;. When your RevenueCat webhook fires to update your database, it can't map an anonymous RC id to a real user row. It either skips the event or writes an orphaned record. Your server's entitlement check returns false for that user.&lt;/p&gt;

&lt;p&gt;The purchase exists in RevenueCat's dashboard. The user paid. Nothing shows up on your backend.&lt;/p&gt;

&lt;p&gt;The fix requires understanding what RC does with identity. You want RC's &lt;code&gt;app_user_id&lt;/code&gt; to be your auth system's stable user id, set BEFORE any purchase happens:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;configuredApiKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;appUserID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;userId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;currentAppUserID&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// User signed in after RC was already configured; switch identity&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;currentAppUserID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loginResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&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;If your auth flow allows anonymous purchases (user buys before creating an account), call &lt;code&gt;logIn()&lt;/code&gt; with the stable user id the moment they sign up. RC handles the client-side transfer from anonymous to identified, but your webhook still has to catch the TRANSFER event and re-key the database row. More on that in #4.&lt;/p&gt;

&lt;p&gt;In my case, Supabase's anonymous auth preserves the user id across the anon-to-email upgrade (&lt;code&gt;auth.updateUser()&lt;/code&gt; keeps the same uuid). So I pass the Supabase uuid to RC from the very first session, before they've ever entered an email. When they eventually link an account, the RC identity is already the final, stable uuid.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. No &lt;code&gt;customerInfo&lt;/code&gt; listener
&lt;/h2&gt;

&lt;p&gt;Here's what happens when you skip a &lt;code&gt;customerInfoUpdateListener&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;A user purchases through your native paywall (via &lt;code&gt;Purchases.presentPaywall()&lt;/code&gt;). The purchase succeeds. Your paywall dismisses. The user is back on the main screen, still seeing the free tier, because the component that owns &lt;code&gt;isPro&lt;/code&gt; state hasn't been told anything changed.&lt;/p&gt;

&lt;p&gt;The listener is how RC pushes out-of-band entitlement changes: purchases through the native paywall, server-side grants, trial conversions, grace-period resolutions. Without it, you're only checking entitlements at cold start.&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="nf"&gt;useEffect&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;let&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onCustomerInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomerInfo&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;checkEntitlements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&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;async &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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onCustomerInfo&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="c1"&gt;// SDK not configured in dev; nothing to listen to&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="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="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onCustomerInfo&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="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;initialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checkEntitlements&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;react-native-purchases&lt;/code&gt; v10 gotcha: &lt;code&gt;addCustomerInfoUpdateListener&lt;/code&gt; returns void. You can't call &lt;code&gt;.remove()&lt;/code&gt; on the return value. Cleanup is &lt;code&gt;removeCustomerInfoUpdateListener(sameRef)&lt;/code&gt;, and sameRef has to be the exact same function reference. Define the listener inside the effect, close over it, pass the same reference to both add and remove.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The RC session leaked between users on the same device
&lt;/h2&gt;

&lt;p&gt;This bug is invisible to unit tests. You'd need an integration test that calls &lt;code&gt;configure()&lt;/code&gt;, then &lt;code&gt;logOut()&lt;/code&gt;, then signs in as a different user and checks their entitlements. Most test suites don't go there.&lt;/p&gt;

&lt;p&gt;RC's SDK uses module-level state. Once &lt;code&gt;Purchases.configure()&lt;/code&gt; has run with User A's identity, that configuration persists until you explicitly clear it. If User A signs out and User B signs in, and your initialization check says "already initialized, skip". User B inherits User A's RC session. If User A was a paying subscriber, User B gets &lt;code&gt;isPro: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reset your initialization bookkeeping on sign-out, right after calling &lt;code&gt;Purchases.logOut()&lt;/code&gt;:&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;// Call this on every sign-out, after Purchases.logOut()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resetRCSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;configuredApiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;currentAppUserID&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Purchases.logOut()&lt;/code&gt; returns RC to an anonymous identity. &lt;code&gt;resetRCSession()&lt;/code&gt; clears your bookkeeping so the next &lt;code&gt;initialize()&lt;/code&gt; doesn't short-circuit on the previous user's state.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The webhook handles purchases but ignores refunds and cancellations
&lt;/h2&gt;

&lt;p&gt;The first three bugs are client-side. This one is server-side, and it's the one that keeps denying access after everything else is right.&lt;/p&gt;

&lt;p&gt;Your subscription table probably gets written on &lt;code&gt;INITIAL_PURCHASE&lt;/code&gt; and &lt;code&gt;RENEWAL&lt;/code&gt;. If you're not handling &lt;code&gt;CANCELLATION&lt;/code&gt;, &lt;code&gt;REFUND&lt;/code&gt;, and &lt;code&gt;TRANSFER&lt;/code&gt;, your database will say &lt;code&gt;is_active: true&lt;/code&gt; for users whose subscriptions have ended. They lose access only when &lt;code&gt;expires_at&lt;/code&gt; passes (if you even store that field).&lt;/p&gt;

&lt;p&gt;The full event set you need, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;: create the subscription row&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RENEWAL&lt;/code&gt;: extend &lt;code&gt;expires_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CANCELLATION&lt;/code&gt;: mark inactive (keep the row; they may have time remaining)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REFUND&lt;/code&gt;: mark inactive immediately&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TRANSFER&lt;/code&gt;: re-key the row when RC reassigns &lt;code&gt;app_user_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;TRANSFER&lt;/code&gt; is the one that closes the loop from bug #1. When an anonymous user creates an account and RC transfers their purchase history to a named identity, a TRANSFER event fires with &lt;code&gt;transferred_from&lt;/code&gt; (the old anonymous id) and &lt;code&gt;app_user_id&lt;/code&gt; (the new identified one). Ignore it, and the purchase stays associated with an id that doesn't map to any user row.&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="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INITIAL_PURCHASE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RENEWAL&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration_at_ms&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration_at_ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CANCELLATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REFUND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TRANSFER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// transferred_from is an array; the relevant old id is [0]&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transferSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferred_from&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_user_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping a RevenueCat-gated feature:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is &lt;code&gt;Purchases.logIn(userId)&lt;/code&gt; called with the stable auth user id before any purchase can happen?&lt;/li&gt;
&lt;li&gt;Is there an active &lt;code&gt;customerInfoUpdateListener&lt;/code&gt; on every screen that gates on &lt;code&gt;isPro&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Does sign-out call &lt;code&gt;Purchases.logOut()&lt;/code&gt; AND reset your initialization bookkeeping?&lt;/li&gt;
&lt;li&gt;Does the webhook handler cover CANCELLATION, REFUND, and TRANSFER, not just INITIAL_PURCHASE?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Credentials being valid is table stakes. The silent failures come after.&lt;/p&gt;




&lt;p&gt;Building with RevenueCat on Expo/React Native and hit a different version of these? Comments are open. Also at &lt;a href="https://raeduslabs.com" rel="noopener noreferrer"&gt;raeduslabs.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>revenuecat</category>
      <category>expo</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <item>
      <title>RevenueCat Said My Play Store Credentials Were Invalid. They Weren't.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sun, 14 Jun 2026 12:11:02 +0000</pubDate>
      <link>https://dev.to/astraedus/revenuecat-said-my-play-store-credentials-were-invalid-they-werent-1pl4</link>
      <guid>https://dev.to/astraedus/revenuecat-said-my-play-store-credentials-were-invalid-they-werent-1pl4</guid>
      <description>&lt;p&gt;I created a Google service account, uploaded the JSON key to RevenueCat, and watched my Android app reject every purchase. The dashboard said the credentials needed attention. On device, the logs showed a RevenueCat error: &lt;code&gt;7107&lt;/code&gt; / &lt;code&gt;Invalid Play Store credentials&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The credentials weren't invalid. I'd just made the mistake that costs a lot of mobile developers a full day: I expected Google to be fast.&lt;/p&gt;

&lt;p&gt;Here are three Google Play Billing traps from one app launch. Two of them are Google being slow. The third costs money instead of time. All three look exactly like bugs in your code, and none of them are.&lt;/p&gt;

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

&lt;p&gt;I'm building Origo, a subscription app on Expo and React Native. Payments run through RevenueCat, which validates every Google Play purchase server-side before it grants entitlements. That server-side check is the whole point. The client can't be trusted to claim "this user paid," so RevenueCat asks Google directly.&lt;/p&gt;

&lt;p&gt;To ask Google, RevenueCat needs a service account with access to your Play Console. You create the service account in Google Cloud, generate a JSON key, grant it permissions in Play Console, and paste the key into RevenueCat. The &lt;a href="https://www.revenuecat.com/docs/service-credentials/creating-play-service-credentials" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; walk through it cleanly.&lt;/p&gt;

&lt;p&gt;Then nothing works, and the docs don't warn you loudly enough about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: "Invalid Play Store credentials" usually means "wait"
&lt;/h2&gt;

&lt;p&gt;The first purchase test failed instantly. RevenueCat's dashboard showed a banner: &lt;code&gt;Credentials need attention&lt;/code&gt;. On device, the SDK threw &lt;code&gt;Invalid Play Store credentials&lt;/code&gt;. My first instinct was that I'd pasted the wrong key or missed a permission.&lt;/p&gt;

&lt;p&gt;I hadn't. The key was correct and the permissions were correct. The problem was time.&lt;/p&gt;

&lt;p&gt;When you create a fresh service account, Google has to propagate its access to the Play Developer API. RevenueCat's &lt;a href="https://revenuecat.zendesk.com/hc/en-us/articles/360046398913-Invalid-Play-Store-credentials-errors" rel="noopener noreferrer"&gt;support docs&lt;/a&gt; put this at 24 to 48 hours. Until it finishes, every server-side validation returns an error that reads like a configuration mistake. So you "fix" a config that was never broken, re-upload the same key, and wait again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The faster fix:&lt;/strong&gt; you can force Google to re-evaluate the credentials instead of waiting on the background job.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Play Console and go to your app.&lt;/li&gt;
&lt;li&gt;Go to &lt;code&gt;Monetize → Products → Subscriptions&lt;/code&gt; (or In-app products).&lt;/li&gt;
&lt;li&gt;Edit any product. Change the description. Save.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Saving a product change pokes the billing config and can validate the new credentials right away, per RevenueCat's own guidance. If you remember one thing from this post, remember that the credential error is a clock, not a bug. Editing a product can reset the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: new products return ITEM_UNAVAILABLE for hours
&lt;/h2&gt;

&lt;p&gt;The second trap looks even more like a real defect. You create a subscription or a one-time product in Play Console, wire it into RevenueCat, run a purchase, and the SDK returns &lt;code&gt;ITEM_UNAVAILABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The product exists. You're staring at it in the console. It still says unavailable.&lt;/p&gt;

&lt;p&gt;Same root cause: propagation. A product you created minutes ago hasn't reached the billing backend yet. One-time products in particular can take hours. I hit this with a lifetime product I'd created minutes before the test, started reading my purchase flow line by line, then realized the product was simply too new to buy.&lt;/p&gt;

&lt;p&gt;The rule I follow now: if a product is less than a few hours old, &lt;code&gt;ITEM_UNAVAILABLE&lt;/code&gt; isn't evidence of anything. Retest later before you touch your code. The most expensive debugging is debugging a system that simply isn't ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: test with license testers, or you'll charge yourself
&lt;/h2&gt;

&lt;p&gt;The first two were Google being slow. This one's different. It's the mistake that turns a test into a real transaction.&lt;/p&gt;

&lt;p&gt;Google Play has no separate sandbox the way Apple does. A purchase on a live product charges a real card unless the buying account is registered as a license tester. So before any purchase test, I add our test Google account under &lt;code&gt;Play Console → Settings → License testing&lt;/code&gt;. License testers get the full purchase flow, real dialogs, real RevenueCat validation, but no charge and no refund paperwork.&lt;/p&gt;

&lt;p&gt;Skip this step and your "test" buys your own subscription with real money. Then you're filing a refund instead of reading logs.&lt;/p&gt;

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

&lt;p&gt;The two delays share one shape. The symptom shows up at the boundary between your app and a platform you don't control, and that platform is eventually consistent. It says no now and yes in a few hours, with nothing in between that admits it's still thinking.&lt;/p&gt;

&lt;p&gt;Your debugging instinct is to assume the last thing you changed is broken. With Google Play Billing, the last thing you changed is often correct and just not live yet. So I changed my checklist. Before I debug a billing error, I ask one question first: how old is the thing that's failing? If the answer is minutes or hours, I wait and retest before I read a single line of my own code.&lt;/p&gt;

&lt;p&gt;That one question has saved me more time than any fix.&lt;/p&gt;

&lt;p&gt;I'm writing up the real launch problems behind Origo as I hit them. If you're shipping subscriptions on React Native, these propagation traps are the ones I wish someone had warned me about on day one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>android</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
  </channel>
</rss>
