◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/App Dev
◇ App Dev/2026-10-15Advanced

My Ads Never Came Back After a Subscription Expired: Fixing the Direction of an Ad-Free Check

An ad-free flag stayed on after the subscription expired because the check pointed the wrong way. Here is how I rebuilt it to wait for two purchase queries, decide only from confirmed facts, and keep the last value on failure.

Ad-FreeSubscriptions17EntitlementsState Management8Ops Log

✦ Premium Article

I was writing out a decision flow line by line one night. One of my wallpaper apps, which I run as an indie developer, removes ads after a purchase. What I had never done, I realized, was trace what happens to that "ads removed" state after the subscription expires.

When I traced it, I stopped. The branch that brings ads back was not written anywhere.

What I had was a lapsed subscription with the ads still off. An ad shown to a paying user gets noticed fast and turns into a support message. The opposite accident, ads that stay off for someone who no longer pays, bothers nobody, so nobody reports it. It simply continues. I found it on the Android side, and it became the first item on the checklist I wrote before carrying the same logic over to iOS.

What I want to say first is that the fix was not about how to use a billing SDK. It was about the direction of the check and the way it waits. What follows is a write-up of that idea, rebuilt as code I could run, and then run.

One sentence that describes the bug: the direction was reversed

Put into words, the broken check read: "if the flag is not ad-free right now, set it to false." What it should have said was: "if no purchase is confirmed, set it to false." The subject was the flag's own current value instead of a confirmed fact.

I will not paste the original code. Here is the idea, reconstructed in TypeScript.

// adfree.ts — composing the ad-free flag from two purchase queries
export type Source = "subscription" | "oneTime"; // subscription / one-time purchase
 
export type QueryResult =
  | { ok: true; active: boolean }
  | { ok: false; reason: string };
 
export type Results = Record<Source, QueryResult>;
 
// A reconstruction of the broken direction (the idea, not a quote of the real code)
export function applyBuggy(prev: boolean, r: Results): boolean {
  const purchased =
    (r.subscription.ok && r.subscription.active) ||
    (r.oneTime.ok && r.oneTime.active);
  if (purchased) return true;
  if (!prev) return false; // "if not ad-free right now, set false": the direction is backwards
  return prev;             // even after expiry, the previous true stays
}

This code works correctly up to the moment a purchase is confirmed, which is why checking right after a purchase reveals nothing. It breaks only once, after the subscription lapses. When the query answers "none" and prev is still true, it keeps returning true.

Tests on the working side never find this kind of bug. "Buy it and the ads disappear" stays green no matter how often you repeat it. Test the direction that removes ads and the direction that brings them back separately. I put that at the top of my checklist.

Wait for both sources, then decide only from confirmed facts

There was not one purchase query but two, for example a subscription and a one-time purchase. If you set false the moment one source says "none", ads flash on screen when the other source is about to say "active".

I rewrote the decision as three rules.

Outcome of the two queriesValue to setWhy
At least one confirmed "active"trueOne piece of proof is enough, even if the other call failed
Both confirmed "inactive"falseYou may only clear the flag after hearing "inactive" from every source
A failure and no proof of activekeep the previous valueDo not manufacture an answer when you cannot decide

Here are those rules as a function.

// Fixed: decide only from confirmed facts; keep the previous value when undecidable
export function composeAdFree(prev: boolean, r: Results): boolean {
  const list = [r.subscription, r.oneTime];
  // One confirmed "active" is enough, even if the other source failed
  if (list.some((x) => x.ok && x.active)) return true;
  // false only when every source confirmed "inactive"
  if (list.every((x) => x.ok && !x.active)) return false;
  // A failure and no proof of active: undecidable, so keep the previous value
  return prev;
}

Why write it this way? The key is giving QueryResult a third state, "failed". active: false means "I asked and it was inactive". ok: false means "I could not ask". If both collapse into one false, a dropped connection becomes a change in billing state.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦You'll be able to check, with a table of two-source outcomes, whether your own app's ad-free flag really returns to false when a subscription lapses
✦You'll have a small decision function that waits for both queries, never invents an answer on a failed call, and can be unit-tested without your billing SDK
✦You'll avoid both opposite accidents before shipping: a paying user who briefly sees an ad, and a lapsed user whose ads never come back
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◇ App Dev2026-10-15
When adjustsFontSizeToFit Shrinks Just One Row, Suspect the Box Height, Not the String
Only the 10th row of a list drops to 4pt on iOS, though the data and strings are fine. I read the Yoga rounding and font-fit sources, then counted how often the box comes up short in Node.
◇ App Dev2026-10-15
When Only EAS Build Fails with "No matching variant", Check Your Local android/build First
Gradle passes on your machine, but the Android build on EAS stops at "No matching variant of project". Reading the reports on GitHub, I traced how a Windows-generated autolinking cache can reach the Linux builder, and wrote a small script that checks for it before you queue a build.
◇ App Dev2026-10-14
App Review Says "Guideline 2.1, Blank Screen": Three Places to Look in an Expo Release Build
It opens fine in TestFlight, yet review rejects it for a blank screen. Here is the order I check things in an Expo + EAS release build: which environment holds your variables, secret visibility during config resolution, and what the reviewer's device does differently.