◉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)●SONNET 4.5 — Retires on the Claude API on 11/30, 36 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●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)●SONNET 4.5 — Retires on the Claude API on 11/30, 36 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●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/Dev Tools
⬡ Dev Tools/2026-07-27Advanced

When to Raise Your Minimum iOS Version — Count Leftover Branches, Not User Percentages

Judging a minimum OS bump by usage share produces the same answer every year, so the decision never happens. Here is the annotation convention, the sweep script that counts how many iOS and Android branches each candidate floor would retire, and what to watch for 30 days after.

Rork600Expo227React Native242Maintenance3iOS116Long-term Operations4

✦ Premium Article

Last autumn I opened the same spreadsheet I had opened the two autumns before it.

I pasted in the session share by OS version from App Store Connect and looked at the oldest row. 0.9 percent. The year before it was 1.4, and before that 2.1. The number was shrinking on schedule.

The conclusion never did. "Still can't drop it," I typed, and closed the sheet.

By the third year I understood that my judgment wasn't the problem. The metric was. Usage share tells you how many people you would stop reaching. It says nothing at all about what you keep carrying if you don't.

Usage share can only argue for waiting

When you maintain several apps on your own, decisions get made in whatever minutes are left over. That's exactly why a single number is so tempting.

The trouble is that usage share only pushes in one direction.

Say your threshold is 1 percent. At 0.9 percent, can you actually pull the trigger? No — you tell yourself it'll be 0.5 next year, so wait. At 0.5 percent you say something different but equivalent: is it really worth taking on release risk to cut a mere half a percent? The denominator shrinks, the pain of cutting shrinks with it, and your internal threshold quietly shrinks right alongside both.

I run six wallpaper apps in parallel. The cost of deferring was accumulating six times over, in a place I wasn't looking.

That place is the branches that paper over OS generation differences.

// A real example, simplified
if (Number.parseInt(String(Platform.Version), 10) >= 17) {
  await Sharing.shareAsync(uri, { UTI: 'public.png' });
} else {
  // Older OS closes the multi-select share sheet halfway through, so send one at a time
  for (const one of uris) {
    await Sharing.shareAsync(one, { UTI: 'public.png' });
  }
}

That branch was correct the day it was written. The problem is that nowhere in the codebase does it say when it becomes safe to delete.

Six months later, I can't tell whether the else path is still load-bearing. Because I can't tell, I don't delete it. Because I don't delete it, every future change to this screen means verifying both paths.

So the real cost of deferring never showed up as lost users. It showed up as a count of branches I couldn't retire.

Which suggested an obvious move: count them directly, and let that count be the input to the decision instead of usage share.

Give the branches one place to live

The first step was deciding where OS checks are allowed to be written, and in what form.

Instead of reading Platform.Version from every screen, I wrapped it in a comparison function. Element-wise numeric comparison, so that string ordering never turns "9.0" into something greater than "10.0".

// src/lib/os.ts
import { Platform } from 'react-native';
 
const parse = (v: string | number): number[] =>
  String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
 
/** True when the running iOS is at least `target`. Always false off iOS. */
export function atLeastIOS(target: string): boolean {
  if (Platform.OS !== 'ios') return false;
  const cur = parse(Platform.Version);
  const req = parse(target);
  for (let i = 0; i < Math.max(cur.length, req.length); i++) {
    const a = cur[i] ?? 0;
    const b = req[i] ?? 0;
    if (a !== b) return a > b;
  }
  return true;
}

I ran the comparison logic against the boundary cases before trusting it: a bare "17" with the minor omitted, "17.0.1" against "17.1", "16.4" against "16.4.1". Seven cases, all matching expectations. Version comparison looks obviously right the moment you write it and falls apart the moment a third component appears.

The floor itself lives in exactly one file.

// src/config/supportMatrix.ts
// The OS floor this app officially supports. This file is the single source of truth.
// Never change it without running os-sweep first.
export const MIN_OS = {
  ios: '16.0',
  android: 31,
} as const;

And every OS branch carries an annotation:

// @drop-when ios>=17.0 -- multi-select share sheet closes early below 17.0
if (!atLeastIOS('17.0')) {
  return <LegacyShareSheet uris={uris} />;
}

The format is deliberately trivial: @drop-when ios>=X, then --, then the reason.

What matters isn't the rigor of the syntax. It's that "when can this go" and "why does this exist" sit two characters away from the branch itself. With both present, future-you can make the call in seconds.

✦

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
✦The full sweep script that reads @drop-when annotations and reports how many iOS and Android branches each candidate minimum would let you delete
✦A three-level exit code design that fails CI on stale branches left behind after a bump, plus the GitHub Actions wiring
✦Why your crash-free rate improves right after a bump, and why recording that as a win will distort next year's decision
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

⬡ Dev Tools2026-05-28
Tracking Down BGTaskScheduler.submit Error Code=1 (Unavailable) in Rork iOS Apps
When BGTaskScheduler.submit returns Error Code=1, the cause space is finite — six of them. Covers telling Code=1 from Code=3, the identifier trap in Expo-based Rork apps, submitting from a widget extension by mistake, and a getPendingTaskRequests self-check.
⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.