<?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: Sourav Kashyap</title>
    <description>The latest articles on DEV Community by Sourav Kashyap (@sourav_kashyap_3239f38dcc).</description>
    <link>https://dev.to/sourav_kashyap_3239f38dcc</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%2F2244143%2Fae0908e9-c5a1-4aa6-ae58-888edd380276.png</url>
      <title>DEV Community: Sourav Kashyap</title>
      <link>https://dev.to/sourav_kashyap_3239f38dcc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourav_kashyap_3239f38dcc"/>
    <language>en</language>
    <item>
      <title>One Sender, Two Kinds of Mail</title>
      <dc:creator>Sourav Kashyap</dc:creator>
      <pubDate>Mon, 17 Aug 2026 07:08:21 +0000</pubDate>
      <link>https://dev.to/sourav_kashyap_3239f38dcc/one-sender-two-kinds-of-mail-3po1</link>
      <guid>https://dev.to/sourav_kashyap_3239f38dcc/one-sender-two-kinds-of-mail-3po1</guid>
      <description>&lt;p&gt;Sending automated alerts and password resets from the same address was quietly wrecking our deliverability. Here's what we changed, including the parts we got wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  canonical_url: &lt;a href="https://production-notes.hashnode.dev/one-sender-two-kinds-of-mail?utm_source=hashnode&amp;amp;utm_medium=feed" rel="noopener noreferrer"&gt;https://production-notes.hashnode.dev/one-sender-two-kinds-of-mail?utm_source=hashnode&amp;amp;utm_medium=feed&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Sending automated alerts and password resets from the same address was quietly wrecking our deliverability. Here's what we changed, including the parts we got wrong first.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What follows is what was actually going on, and what we changed. Some of it we got right the first time. A fair bit of it we didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three bugs that were one bug
&lt;/h2&gt;

&lt;p&gt;The reports trickled in over a few weeks, and because they arrived separately they each looked like their own small problem.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dispatcher never received their login code.&lt;/li&gt;
&lt;li&gt;A carrier said the rate confirmation "never arrived" — it was in Junk.&lt;/li&gt;
&lt;li&gt;An invoice landed in spam for one customer but inbox for another.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We chased each one individually. Was it the template? A bad &lt;code&gt;From&lt;/code&gt; address? Some specific corporate mail server being aggressive?&lt;/p&gt;

&lt;p&gt;It was none of those. Every one of those emails had sent successfully. SendGrid accepted them and reported them delivered, and then Gmail filed them under Junk without telling anybody. Our provider dashboard stayed green the whole time.&lt;/p&gt;

&lt;p&gt;What took us embarrassingly long to work out is that there was nothing wrong with any individual email. The problem was that we had &lt;strong&gt;one sender reputation and we were spending it on the wrong mail.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually wrong
&lt;/h2&gt;

&lt;p&gt;We run a logistics platform that sends a &lt;em&gt;lot&lt;/em&gt; of email. It divides cleanly in two, though we weren't treating it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mail a human is waiting for, right now:&lt;/strong&gt; login codes, password resets, invoices and settlements, rate confirmations, account invitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mail a machine generated on a schedule:&lt;/strong&gt; maintenance due and overdue, compliance documents expiring, low fuel and low DEF, speeding alerts, reefer temperature out of range, load status updates, marketplace bid blasts to hundreds of carriers, daily ticket digests.&lt;/p&gt;

&lt;p&gt;Every one of these went out the same way — &lt;code&gt;sendMail({ to, subject, html })&lt;/code&gt;. Same sender domain, same reputation pool, no distinguishing metadata whatsoever.&lt;/p&gt;

&lt;p&gt;The thing we'd missed is that mailbox providers don't really judge emails, they judge &lt;strong&gt;senders&lt;/strong&gt;. From Gmail's side, our sender was an address pushing out a growing volume of automated notifications that recipients mostly didn't open, with no way to unsubscribe from any of it. That profile looks a lot like a spammer, and Gmail treated it accordingly.&lt;/p&gt;

&lt;p&gt;So it throttled us. Not the alerts specifically — the sender. Which meant the password reset our customer was waiting on got throttled too, for reasons that had nothing to do with password resets.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By default your bulk mail and your transactional mail share one reputation. &lt;strong&gt;The bulk mail earns the complaints and the transactional mail pays for them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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%2Fg6b7tynteg8gcqogkccs.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%2Fg6b7tynteg8gcqogkccs.png" alt=" " width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before.&lt;/strong&gt; &lt;em&gt;Every kind of mail left through one untagged sender, so one reputation covered all of it. The complaints came from the automated alerts on the left; the throttling they earned applied to the password reset at the bottom just the same.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There was a hard deadline attached, too. Gmail and Yahoo's bulk sender requirements mandate one-click unsubscribe and &lt;code&gt;List-Unsubscribe&lt;/code&gt; headers on bulk mail, with enforced complaint-rate thresholds. We weren't just degrading — we were non-compliant, and heading toward being blocked outright rather than merely filtered.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Every email declares what kind of email it is, and that declaration decides whether it can be unsubscribed from.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which sounds too simple to take a sprint. The idea is easy; the work was getting it applied consistently across a few hundred send sites, and making sure it stayed applied after we stopped paying attention.&lt;/p&gt;

&lt;p&gt;Our first attempt didn't do that. We added an optional &lt;code&gt;category&lt;/code&gt; parameter to the mail helper and updated the call sites we could find by grepping. That got maybe eighty percent of them. The rest kept sending uncategorized, and because the parameter was optional nothing complained. We only noticed when a report digest turned up in a suppression list it had no business being in. That's what pushed us toward making the type system do the work instead of us.&lt;/p&gt;

&lt;h2&gt;
  
  
  A taxonomy, not a boolean
&lt;/h2&gt;

&lt;p&gt;The obvious move is a boolean: &lt;code&gt;isTransactional: true&lt;/code&gt;. We argued about this for a while and ended up not doing it.&lt;/p&gt;

&lt;p&gt;The problem with a boolean is that it gives you exactly one unsubscribe group covering all bulk mail. A fleet manager who's tired of reefer temperature alerts would have to unsubscribe from everything, marketplace bids included, and those bids are the emails he actually wants. When you give people that choice they usually pick one of two bad options: put up with the noise until they eventually hit "Report Spam" (which is the thing you were trying to avoid), or opt out wholesale and miss something that mattered.&lt;/p&gt;

&lt;p&gt;So we went with eight categories instead. One of them behaves differently from the other seven.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Illustrative — the shape, not our source&lt;/em&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EMAIL_CATEGORY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// User-triggered, 1:1. No unsubscribe group. Never suppressible.&lt;/span&gt;
  &lt;span class="na"&gt;TRANSACTIONAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transactional&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;// Everything below is machine-generated → gets an unsubscribe group.&lt;/span&gt;
  &lt;span class="na"&gt;EQUIPMENT_ALERTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;equipment_alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;SENSOR_ALERTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sensor_alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ORDER_UPDATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order_updates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;MARKETPLACE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marketplace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;BILLING_NOTICES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing_notices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// reminders, NOT the invoice&lt;/span&gt;
  &lt;span class="na"&gt;REPORTS_DIGESTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports_digests&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PRODUCT_UPDATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product_updates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seven non-transactional categories each map to a &lt;strong&gt;provider-side unsubscribe group&lt;/strong&gt;. &lt;code&gt;TRANSACTIONAL&lt;/code&gt; maps to nothing, by design. That distinction is the entire architecture.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What each class of mail carries&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Category tag&lt;/th&gt;
&lt;th&gt;Unsub group&lt;/th&gt;
&lt;th&gt;Suppressible&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transactional&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Everything else&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;per-category&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fks6bstvhoriu9zdusuav.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%2Fks6bstvhoriu9zdusuav.png" alt=" " width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After.&lt;/strong&gt; &lt;em&gt;The category attached at the send site decides everything downstream. A complaint about reefer alerts now becomes an opt-out from **that one group&lt;/em&gt;* — it never reaches the transactional lane below the dotted line.*&lt;/p&gt;

&lt;p&gt;The comment on &lt;code&gt;BILLING_NOTICES&lt;/code&gt; is doing more work than it looks. A reminder that your trial ends next week is marketing, so you should be able to turn it off. The invoice itself is a financial document, so you shouldn't. Drawing that line correctly for each category took us longer than writing any of the code, and it isn't really an engineering question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make forgetting a compile error
&lt;/h2&gt;

&lt;p&gt;A taxonomy that relies on developers remembering to use it will be wrong within two sprints. Someone adds a notification type, doesn't pass a category, and it silently defaults to uncategorized — back to the original bug, one email at a time.&lt;/p&gt;

&lt;p&gt;We closed that with an exhaustive &lt;code&gt;Record&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Illustrative — the map that cannot be left incomplete&lt;/em&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;// Keyed by EVERY notification type. Adding one without deciding its&lt;/span&gt;
&lt;span class="c1"&gt;// category is a COMPILE ERROR, not a runtime surprise.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CATEGORY_BY_NOTIFICATION&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="nx"&gt;NotificationType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;EmailCategory&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="na"&gt;OrderAssigned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;EMAIL_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ORDER_UPDATES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ServiceDueSoon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;EMAIL_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EQUIPMENT_ALERTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;SensorOutOfRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;EMAIL_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENSOR_ALERTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PasswordReset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;EMAIL_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TRANSACTIONAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ... every notification type in the system&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's a total &lt;code&gt;Record&lt;/code&gt; over the notification-type union rather than a &lt;code&gt;Partial&lt;/code&gt;, you can't add a notification type without assigning a category. The build fails. So the decision gets made by whoever is adding the notification, at the point where they still have all the context about what it is, instead of surfacing months later as a support ticket.&lt;/p&gt;

&lt;p&gt;If there's one piece of this worth copying, it's that. We'd tried the wiki-page version and it lasted about three weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the mail package dumb
&lt;/h2&gt;

&lt;p&gt;Our shared mail package is a thin wrapper over the provider, used by several services. It deliberately has no database access, and we wanted to keep it that way. But turning a category into an unsubscribe-group ID &lt;em&gt;needs&lt;/em&gt; a lookup. So we split the job three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shared package owns the taxonomy — pure types and constants, no I/O.&lt;/li&gt;
&lt;li&gt;One service owns ID resolution — async, database-backed, cached.&lt;/li&gt;
&lt;li&gt;The mail package accepts an already-resolved number and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Illustrative — the mail layer stays dumb&lt;/em&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;// The transport takes an already-resolved number and nothing more.&lt;/span&gt;
&lt;span class="c1"&gt;// No database, no category lookup, no policy decisions.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;asm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&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;Call sites resolve the ID asynchronously, then pass a plain number in. The mail package stays DB-agnostic and trivially testable.&lt;/p&gt;

&lt;p&gt;One deliberate choice about where the IDs live: not environment variables. Seven env vars across every host means a DevOps ticket every time a group changes. Instead they live in a single application-settings row, editable at runtime through an admin screen and cached briefly.&lt;/p&gt;

&lt;p&gt;And critically — the resolver &lt;strong&gt;fails safe&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The invariant worth writing down&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transactional mail must NEVER carry an unsubscribe group.

Group resolution returns nothing for transactional mail, and nothing
for any category not yet configured — fail-safe: the email still
sends, just without a group-unsubscribe link.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the config is missing or malformed, the mail still goes out, just without an unsubscribe link on it. That's a deliberate trade. Missing an unsubscribe link is a compliance problem we can fix on Monday. Failing to deliver a password reset is an outage. Given a choice about which way to fail, we'd rather fail toward the one that doesn't lock people out of their accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the seed file caused
&lt;/h2&gt;

&lt;p&gt;This next one is the failure I think about most, because there was nothing in the diff for a reviewer to catch.&lt;/p&gt;

&lt;p&gt;The code was right, the types were exhaustive, the tests passed. And for about a week after we deployed, &lt;strong&gt;every single alert still went out with no unsubscribe link on it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The settings row holding the category-to-group-ID map had never been created in the deployed environments. We'd added it locally while developing and never thought about it again. So group resolution came back empty for every category, took the fail-safe path exactly as written, and quietly sent everything without a group. Working as designed and completely wrong at the same time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The fail-safe did exactly what we built it to do, and in doing so it hid the fact that the feature wasn't running at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was an idempotent, create-only seed that runs on every deploy: it establishes the seven mappings once if they're absent, and deliberately does &lt;em&gt;not&lt;/em&gt; overwrite them if they already exist.&lt;/p&gt;

&lt;p&gt;The bigger lesson was about the fail-safe itself. Falling back gracefully is the right behaviour, but a fallback nobody can see isn't graceful, it's just invisible. We added a warning when resolution comes back empty. It fires once per category per process rather than once per email, which we learned the hard way after a marketplace blast resolved the group per recipient and produced several thousand identical log lines in about a minute.&lt;/p&gt;

&lt;p&gt;The create-only part of the seed matters too, and it's easy to get backwards. If the seed overwrote existing values on every deploy, any change an admin made through the settings screen would silently revert the next time we shipped. Establishing the value once and then leaving it alone is what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give users a real preferences page
&lt;/h2&gt;

&lt;p&gt;SendGrid hosts an unsubscribe preference page for free, and we shipped with it. But it has two problems: it's only reachable from an email, and it's SendGrid-branded. Users expect to manage notifications &lt;em&gt;inside the product&lt;/em&gt;, and support staff want to fix a recipient's subscriptions without opening a third-party console.&lt;/p&gt;

&lt;p&gt;So we built an in-app preferences screen. The single most important decision was this one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Source of truth: mirror SendGrid. No new DB schema.&lt;/strong&gt; The screen reads and writes SendGrid suppression data directly, so it can never drift from the email unsubscribe links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tempting version is a local preferences table. Reads are faster and you're not dependent on a third party being up. It also guarantees you a consistency problem: someone clicks unsubscribe in an email, so the provider knows about it, then opens your settings page, which doesn't, and sees themselves still subscribed. Now you're writing reconciliation logic and webhook handlers and deciding which side wins a conflict, permanently, to save a couple hundred milliseconds on a page almost nobody visits.&lt;/p&gt;

&lt;p&gt;Reading through to the provider avoids all of that. The screen ends up being a fairly thin client over three calls: read the suppressions for an address, add one, remove one. Writes only send the categories that actually changed, so flipping a single toggle makes one call rather than seven.&lt;/p&gt;

&lt;h3&gt;
  
  
  The identity detail that matters
&lt;/h3&gt;

&lt;p&gt;One rule here mattered more than the rest: &lt;strong&gt;the endpoint for managing your own preferences doesn't take an email address at all.&lt;/strong&gt; It works out who you are from your session and ignores anything the client sends it.&lt;/p&gt;

&lt;p&gt;Had we let it accept an address from the request body, anyone could have unsubscribed anyone from anything. It's the sort of thing you can guard with a validation check, but it's safer to not have the parameter in the first place, so we didn't add one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap in the admin endpoint
&lt;/h2&gt;

&lt;p&gt;Support staff need to be able to fix a recipient's preferences on their behalf, which means an admin version of the endpoint that &lt;em&gt;does&lt;/em&gt; take an email address as input. This is where multi-tenant applications tend to get caught out, and it's worth walking through the shape of it because the mistake is easy to make and hard to spot in review.&lt;/p&gt;

&lt;p&gt;Sketch the naive version and it looks completely reasonable. The route is permission-gated. The caller is a legitimate admin with a real need. The provider call is the same one the self-service path already uses. Every individual piece checks out.&lt;/p&gt;

&lt;p&gt;The problem is what sits between them. Your provider's suppression list is global to your account, with no notion of tenants in it at all. Your application is multi-tenant. If nothing in the middle enforces that boundary, an admin at one tenant can pass in an address belonging to another tenant's user and the provider will happily serve it. Classic IDOR, arrived at without anyone writing anything that looks wrong.&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%2Fsl675ctqlmqq9we28w06.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%2Fsl675ctqlmqq9we28w06.png" alt=" " width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The missing boundary.&lt;/strong&gt; &lt;em&gt;The provider's suppression list is global; the application is multi-tenant. Nothing in the stack reconciles those two facts for you, so the check is yours to write. Note **404 rather than 403&lt;/em&gt;* on the reject path: a 403 would confirm the address exists somewhere and turn the endpoint into an enumeration oracle.*&lt;/p&gt;

&lt;p&gt;The shape of the fix is to resolve the supplied address against your own user table first, scoped to the caller's tenant, and only hand it to the provider once it comes back. Anything that doesn't resolve gets rejected before the external call happens.&lt;/p&gt;

&lt;p&gt;The status code on that rejection matters more than it looks. A &lt;code&gt;403&lt;/code&gt; tells the caller the address exists somewhere in the system but isn't theirs, which is enough to enumerate other tenants' users one guess at a time. A &lt;code&gt;404&lt;/code&gt; returns the same answer whether the address is absent entirely or simply out of scope, so there's nothing to learn from probing it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A global third-party API sitting inside a multi-tenant app is a tenant boundary, and &lt;strong&gt;nothing in your framework knows that.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This generalises well past email. SendGrid, Stripe, Twilio and most of their peers have no concept of your tenants, so any call that forwards a user-supplied identifier is a place where the scoping is yours to do. It's worth grepping for that pattern across your own codebase; the email endpoint is rarely the only instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop poisoning your own reputation with test data
&lt;/h2&gt;

&lt;p&gt;A small one with an outsized effect. Our end-to-end suite ran against a staging environment and generated signups with throwaway addresses at &lt;code&gt;example.com&lt;/code&gt;. Those reached the provider, hard-bounced (nobody owns &lt;code&gt;example.com&lt;/code&gt;), and hard bounces are one of the strongest negative reputation signals there is.&lt;/p&gt;

&lt;p&gt;Which meant that on every CI run we were demonstrating to Gmail that our sender emails people who don't exist.&lt;/p&gt;

&lt;p&gt;We put the filter inside the mail helper itself rather than in the test setup or behind an environment check, for reasons I'll come back to below.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Illustrative — the guard belongs at the exit&lt;/em&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="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="s2"&gt;never hands a test address to the provider&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="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;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qa@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test&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;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blocked test recipient&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;objectContaining&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;blocked&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="s2"&gt;qa@example.com&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Batch sends drop the blocked recipients and deliver to everyone else, so one test address sitting in a marketplace blast doesn't take the whole batch down with it.&lt;/p&gt;

&lt;p&gt;The reason it lives in the mail helper is that the test suite isn't the only way these addresses get in. They arrive through manual QA on staging, through imported CSVs, and through demo accounts somebody set up two years ago and forgot about. Filtering at the point of send catches all of those; filtering in the test config catches one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd tell you to do first
&lt;/h2&gt;

&lt;p&gt;If you send alerts and password resets from the same sender, you probably have some version of this already; you just haven't had the support call yet. Roughly in order of how much they bought us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split transactional from bulk before anything else.&lt;/strong&gt; Most of the benefit is here, and even a rough split helps. You can refine the categories later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the category a required argument, not an optional one.&lt;/strong&gt; We learned this by shipping the optional version first and watching a fifth of our send sites quietly ignore it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get &lt;code&gt;List-Unsubscribe&lt;/code&gt; onto your bulk mail.&lt;/strong&gt; Gmail and Yahoo require it for bulk senders, and an unsubscribe click costs you one recipient where a spam complaint costs you a bit of your sender reputation with everybody.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read subscription state from your provider rather than keeping your own copy.&lt;/strong&gt; If you keep a copy you will eventually have to explain which version is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter test addresses in the mail layer.&lt;/strong&gt; Hard bounces to &lt;code&gt;example.com&lt;/code&gt; cost you reputation and buy you nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go looking for other places you hand a user-supplied identifier to a third party.&lt;/strong&gt; Anywhere the external API is global and your app isn't, the scoping is yours to write.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The part that stuck with me
&lt;/h2&gt;

&lt;p&gt;The thing I'd keep out of all of this isn't any of the code. It's the comment we left at the top of the taxonomy file, explaining why the file exists at all — that mixing bulk alerts with transactional mail on one reputation drags the whole account toward spam.&lt;/p&gt;

&lt;p&gt;Sometime next year somebody will add a notification type, hit the compile error, and open that file to find out which category they're meant to pass. The type system will have forced them to make a decision. Whether they make a good one depends on whether they understand that choosing an alert category over the transactional one is the difference between a user muting a notification and a user not being able to get back into their account.&lt;/p&gt;

&lt;p&gt;The compiler can make you decide. It can't tell you what the right answer is. That part still has to be written down for the next person.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We happened to be on SendGrid, but none of this is specific to it. Any provider with unsubscribe groups and a suppression API will let you do the same thing under different names.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The Crash JavaScript Couldn't Catch: Fixing Android Native Crashes in a React Native App</title>
      <dc:creator>Sourav Kashyap</dc:creator>
      <pubDate>Thu, 06 Aug 2026 05:54:46 +0000</pubDate>
      <link>https://dev.to/sourav_kashyap_3239f38dcc/the-crash-javascript-couldnt-catch-fixing-android-native-crashes-in-a-react-native-app-1mfc</link>
      <guid>https://dev.to/sourav_kashyap_3239f38dcc/the-crash-javascript-couldnt-catch-fixing-android-native-crashes-in-a-react-native-app-1mfc</guid>
      <description>&lt;p&gt;Our React Native app has one job that can't fail: know where the truck is.&lt;/p&gt;

&lt;p&gt;Truxo Tracker is the driver app for &lt;a href="https://truxo.ai" rel="noopener noreferrer"&gt;truxo.ai&lt;/a&gt;. Drivers accept freight loads, and the app runs background location tracking on Android — through the night, through dead zones in rural Texas, through Android deciding it knows better and killing the process. If tracking silently dies, a dispatcher loses visibility on a $40,000 load. If the app crashes in the driver's pocket at 3 AM, tracking dies.&lt;/p&gt;

&lt;p&gt;The app is built with React Native and Expo. For most of what we do, that's been a great trade. But Android background location is exactly the place where the abstraction gets thin, and over the last ten months I've spent a lot of time on the other side of it — reading Java stack traces in Crashlytics, patching Kotlin, and learning things about &lt;code&gt;JobScheduler&lt;/code&gt; I never wanted to know.&lt;/p&gt;

&lt;p&gt;Here's the whole journey at a glance, then the story — including the part where one of my fixes caused a worse bug.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftlgnwzzagher3w13nqbu.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%2Ftlgnwzzagher3w13nqbu.png" alt=" " width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Android NullPointerException that started it all
&lt;/h2&gt;

&lt;p&gt;Late last year, Firebase Crashlytics started filling up with variations of this native crash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Fatal&lt;/span&gt; &lt;span class="nl"&gt;Exception:&lt;/span&gt; &lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RuntimeException&lt;/span&gt;
  &lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NullPointerException&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;expo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;modules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TaskJobService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onStartJob&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;expo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;modules&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TaskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleJob&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nc"&gt;LocationTaskConsumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeTaskWithLocationBundles&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nc"&gt;TaskService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeTask&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a JavaScript error. Not a red screen. A native Java NullPointerException inside &lt;code&gt;expo-task-manager&lt;/code&gt; — the code path that wakes a React Native app up to deliver background location updates on Android.&lt;/p&gt;

&lt;p&gt;The first round of fixes, back in January, was the honest-looking stuff — the things that were actually our fault:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We were using &lt;code&gt;expo-background-task&lt;/code&gt; for something it was never meant to do. Removed it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TaskManager.defineTask&lt;/code&gt; has to run at app startup, before anything else, every time. Ours could race. Fixed the registration order.&lt;/li&gt;
&lt;li&gt;We were trying to start an Android foreground service while the app was backgrounded, which newer Android versions punish severely.&lt;/li&gt;
&lt;li&gt;Added defensive guards around the whole background task execution path in JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Crash volume dropped. But one family of NPEs kept coming back, and after staring at enough Crashlytics stack traces the chain became clear. There were two distinct root causes, and both of them lived below the JavaScript bridge:&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%2F26b4m7nwr02anli3olbo.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%2F26b4m7nwr02anli3olbo.png" alt=" " width="569" height="1210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable part: &lt;strong&gt;no amount of JavaScript try-catch can fix a native Android crash.&lt;/strong&gt; The exception is thrown in Java/Kotlin before a single byte reaches the JS runtime. Your beautiful React error boundary never gets a vote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patching native code in node_modules with an Expo config plugin
&lt;/h2&gt;

&lt;p&gt;The bugs lived in &lt;code&gt;expo-task-manager&lt;/code&gt;'s native source, inside &lt;code&gt;node_modules&lt;/code&gt;. We filed upstream, but drivers were crashing &lt;em&gt;now&lt;/em&gt;, and forking the module meant maintaining a fork forever.&lt;/p&gt;

&lt;p&gt;Expo has an escape hatch for this: config plugins with &lt;code&gt;withDangerousMod&lt;/code&gt;. It's a hook that runs during &lt;code&gt;expo prebuild&lt;/code&gt;, after &lt;code&gt;node_modules&lt;/code&gt; exists but before the Android project is compiled. So I wrote an Expo config plugin that patches the vendored native source at build time:&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;withLocationTaskSafetyPatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConfigPlugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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;return&lt;/span&gt; &lt;span class="nf"&gt;withDangerousMod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="s2"&gt;android&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="nf"&gt;patchTaskJobService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// try-catch at the top of the job chain&lt;/span&gt;
      &lt;span class="nf"&gt;patchLocationTaskConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// null-check the coords bundle&lt;/span&gt;
      &lt;span class="nf"&gt;patchTaskService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// guard the null app loader&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&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;Each patch is idempotent — it stamps a marker comment (&lt;code&gt;TRUXO_SAFETY_PATCH_V3&lt;/code&gt;) into the file and skips if it's already there — so it survives &lt;code&gt;yarn install&lt;/code&gt;, CI, and EAS builds. &lt;code&gt;TaskJobService.onStartJob&lt;/code&gt; got wrapped in a try-catch that calls &lt;code&gt;jobFinished()&lt;/code&gt; and bails instead of letting the exception bubble up to &lt;code&gt;JobServiceEngine$JobHandler&lt;/code&gt;, which wraps anything it catches in a &lt;code&gt;RuntimeException&lt;/code&gt; and kills your process.&lt;/p&gt;

&lt;p&gt;Yes, this is patching other people's code in &lt;code&gt;node_modules&lt;/code&gt;. It's also versioned, documented, marker-guarded, and it shipped the same week. Sometimes the pragmatic thing and the pretty thing are different things.&lt;/p&gt;

&lt;p&gt;That config plugin started at 555 lines in February. It did not stay that size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncatchable SecurityException: when R8 strips your try-catch
&lt;/h2&gt;

&lt;p&gt;March brought a new Android crash. When a driver revoked location permission &lt;em&gt;while tracking was active&lt;/em&gt; — which happens more than you'd think, usually because Android's own settings prompt suggested it — Google Play Services threw a &lt;code&gt;SecurityException&lt;/code&gt; from an async callback posted straight onto the main &lt;code&gt;Handler&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I wrapped the call site in try-catch. It kept crashing.&lt;/p&gt;

&lt;p&gt;The reason took a while to accept: &lt;strong&gt;R8 was stripping the try-catch from the Kotlin lambda bytecode.&lt;/strong&gt; Even with optimization flags dialed down, the exception surfaced in a synthetic lambda class where our handler simply didn't exist anymore, dispatched asynchronously so there was no call stack of ours anywhere near it.&lt;/p&gt;

&lt;p&gt;When there is genuinely no call site you can defend, there's one hook left: a global &lt;code&gt;Thread.UncaughtExceptionHandler&lt;/code&gt; installed in &lt;code&gt;MainApplication.onCreate&lt;/code&gt;, checking the stack for this exact signature — a &lt;code&gt;SecurityException&lt;/code&gt; originating in &lt;code&gt;LocationTaskConsumer&lt;/code&gt; / &lt;code&gt;LocationModule&lt;/code&gt; — and swallowing that one specific crash while passing everything else through to Crashlytics.&lt;/p&gt;

&lt;p&gt;The config plugin grew a patch that injects this handler into &lt;code&gt;MainApplication.kt&lt;/code&gt;. Around the same time we also wrote our own small pieces of native Android code — a &lt;code&gt;BootReceiver&lt;/code&gt; to resume location tracking after device restarts and a &lt;code&gt;LocationTrackingService&lt;/code&gt; with sane lifecycle handling — because at some point writing your own 200 lines of Kotlin is easier than defending someone else's.&lt;/p&gt;

&lt;p&gt;Crash rate kept falling. I felt pretty good. That lasted about two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  How my crash fix caused an Android ANR
&lt;/h2&gt;

&lt;p&gt;Somewhere along the way I had gotten greedy with the global exception handler. Instead of matching only the GMS &lt;code&gt;SecurityException&lt;/code&gt;, I broadened it to swallow &lt;em&gt;any&lt;/em&gt; exception whose stack touched the task manager classes. More crashes caught, right?&lt;/p&gt;

&lt;p&gt;In May, Crashlytics started showing ANRs (Application Not Responding) at &lt;strong&gt;process shutdown&lt;/strong&gt;. The trace was bizarre: &lt;code&gt;DestroyJavaVM&lt;/code&gt; hanging forever. It took real digging to connect it back to my own handler:&lt;/p&gt;

&lt;p&gt;When the broad handler swallowed a &lt;code&gt;JobScheduler&lt;/code&gt; exception, it never called &lt;code&gt;jobFinished()&lt;/code&gt;. The process kept running with a half-shut-down job and orphaned non-daemon threads — JobScheduler internals, wake locks — still alive. Later, when Android decided to kill the process, &lt;code&gt;DestroyJavaVM&lt;/code&gt; sat waiting on those threads. Forever. That wait is an ANR, and it counts against you in Google Play vitals just like a crash.&lt;/p&gt;

&lt;p&gt;The fix — patch version V8, if you're counting — went the other direction:&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%2Fawqmsaiuecxxcpy48pyy.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%2Fawqmsaiuecxxcpy48pyy.png" alt=" " width="726" height="910"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The global handler went back to being &lt;strong&gt;narrow&lt;/strong&gt; — it only matches the R8-stripped GMS &lt;code&gt;SecurityException&lt;/code&gt;, nothing else. All the other native paths are covered by source-level try-catch patches with &lt;code&gt;@Keep&lt;/code&gt; annotations so R8 leaves them alone.&lt;/li&gt;
&lt;li&gt;And when the narrow handler does fire, it no longer just swallows and hopes. It logs a non-fatal to Crashlytics for visibility, then calls &lt;code&gt;Process.killProcess&lt;/code&gt; on itself. A SIGKILL skips &lt;code&gt;DestroyJavaVM&lt;/code&gt; entirely — no waiting on orphaned threads, no ANR — and &lt;code&gt;JobScheduler&lt;/code&gt; restarts the app cleanly when the next location job fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deliberately killing your own process as the &lt;em&gt;fix&lt;/em&gt; is a strange thing to type into a commit message. But a clean, instant death that Android recovers from beats a zombie process that shows up in Play vitals as an ANR.&lt;/p&gt;

&lt;p&gt;The lesson I'd underline twice: &lt;strong&gt;a catch-all exception handler is not a safety net, it's a liability with a delay on it.&lt;/strong&gt; Every exception you swallow is a contract you're breaking with whoever threw it. &lt;code&gt;JobScheduler&lt;/code&gt; expected &lt;code&gt;jobFinished()&lt;/code&gt;. I didn't deliver. It collected two months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ten months of crash fixing bought us
&lt;/h2&gt;

&lt;p&gt;The native layer is only part of it — the same period included hardening the JavaScript side (null-page guards in our infinite queries, local error boundaries so a bad screen shows a Retry button instead of a white void, an offline outbox for chat), adding PostHog session replay and error tracking so we see problems before drivers report them, and starting a flag-gated migration to a dedicated native background-geolocation engine that runs in parallel with the old stack so we can compare them on real devices before switching.&lt;/p&gt;

&lt;p&gt;But the shape of the journey is what sticks with me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fix your own bugs first.&lt;/strong&gt; Most of our "native crashes" started as our misuse of the APIs — wrong task registration order, wrong service start timing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crashlytics stack traces are the actual spec.&lt;/strong&gt; Every durable fix came from reading the full native chain, not from the top frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript error handling ends at the bridge.&lt;/strong&gt; If the crash is in Java before your code runs, no JS construct will save you. You need a native answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch at build time, with markers and versions.&lt;/strong&gt; Our Expo config plugin is on V8, with explicit upgrade paths from every earlier version. It's infrastructure now, not a hack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the fix didn't just move the problem.&lt;/strong&gt; The broad exception handler traded visible crashes for delayed ANRs. Watch your Play vitals &lt;em&gt;after&lt;/em&gt; shipping, not just your crash count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Drivers don't know any of this happened. The app just stopped dying in their pockets, and loads stopped going dark. That's the whole point — the best infrastructure work is the kind nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ: Android native crashes in React Native
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why doesn't try-catch fix native crashes in React Native?&lt;/strong&gt;&lt;br&gt;
Because the exception is thrown in Java or Kotlin before execution ever reaches the JavaScript runtime. JS-level try-catch and React error boundaries only cover code running on the JS thread; a crash in a native module, a &lt;code&gt;JobService&lt;/code&gt;, or a GMS callback kills the process directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you patch a native bug in an Expo module without forking it?&lt;/strong&gt;&lt;br&gt;
Write an Expo config plugin using &lt;code&gt;withDangerousMod&lt;/code&gt;. It runs during &lt;code&gt;expo prebuild&lt;/code&gt; — after &lt;code&gt;node_modules&lt;/code&gt; is installed, before the native build — so you can modify the module's Java/Kotlin source in place. Make the patch idempotent with a marker comment so repeated builds don't double-apply it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What causes ANRs at process shutdown (&lt;code&gt;DestroyJavaVM&lt;/code&gt; hang)?&lt;/strong&gt;&lt;br&gt;
Orphaned non-daemon threads. If something (like a swallowed &lt;code&gt;JobScheduler&lt;/code&gt; exception that never reached &lt;code&gt;jobFinished()&lt;/code&gt;) leaves native threads alive, the JVM waits on them during teardown and Android reports an ANR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can R8 really remove my try-catch blocks?&lt;/strong&gt;&lt;br&gt;
In Kotlin lambda and synthetic-class bytecode, yes — R8 optimization can restructure code so your handler no longer wraps the throwing frame. &lt;code&gt;@Keep&lt;/code&gt; annotations and source-level patches in the module itself are more reliable than call-site try-catch for these paths.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on the mobile team at &lt;a href="https://truxo.ai" rel="noopener noreferrer"&gt;Truxo&lt;/a&gt;, where we build real-time freight load tracking. If you've fought similar battles with React Native background location on Android, I'd genuinely like to compare notes.&lt;/em&gt;&lt;/p&gt;

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