Skip to content
Engineering
6 min read

An Update available prompt in seven apps, without a backend

How my seven iOS apps spot a newer App Store version with Apple's public lookup, once a day, without nagging, in ten languages and with no server of my own.

Cover: An Update available prompt in seven apps, without a backend
On this page
  1. Apple already runs the backend
  2. How often it checks
  3. Comparing versions without lying
  4. Not nagging
  5. Where the seven differ
  6. Ten languages, three events, and how I test it
  7. Takeaways

Not everyone has automatic updates turned on. When I ship a fix, some people keep running the old build for weeks, and then hit the bug I already fixed. Most of my apps have no server of my own, so I had no remote config to say "please update". What I wanted was a small card: "Version 1.6 is available. Update for the latest features and fixes", with Update and Later.

I built it first in Minivid, then put the same feature into the other six. Linkeeper's feature doc says it was "ported from Minivid's implementation (same pure module and tests)", and Baton's and Stampzy's docs end with "keep them alike". This post is how it works, and where the seven versions deliberately differ.

The update card on Minivid, Talkzy and Stampzy home screens
The same card in three design systems: Minivid, Talkzy, Stampzy

Apple already runs the backend

The App Store has a public lookup endpoint. Ask it about your app and it returns JSON with the current store version and the trackId. That is the whole "server":

txt
GET https://itunes.apple.com/lookup?id=<store id>&country=<region>&t=<day number>

Minivid, Linkeeper and Lockboxy ask by store id; Ringsy, Talkzy, Baton and Stampzy ask by bundle id (bundleId=). Either way, three details matter:

  • country is the phone's region, so the answer comes from the storefront the person actually buys from. If that storefront has no result, the code retries once without country and gets the default store.
  • t is the day number (Math.floor(now / DAY_MS)). Apple's CDN can keep serving the old version for hours after a release; a value that changes daily means a stale copy can't hide a release for long.
  • Nothing else. Only the store id or bundle id and the region reach Apple. No identifier, nothing about videos, scripts or vault contents.

Parsing is defensive. parseLookup takes the first result and returns null unless version is a string, trackId is a number, and the version is a plain dotted number. A missing app, an empty results array or unexpected JSON all mean "no update".

How often it checks

The card checks when its screen mounts and every time the app comes back to the foreground (AppState turning active). The store throttles that to at most one lookup per 24 hours (CHECK_INTERVAL_MS). The code comment explains why: "Apple's CDN caches the answer for about that long anyway."

Every lookup is fire-and-forget with a 5 second timeout through an AbortController. The comment at the top of Minivid's module sets the bar: "any failure is silence: this must never slow a launch or show an error." Offline, a timeout, a 500 or bad JSON: no card, no toast, nothing in the way.

Flow diagram of the update check
From launch to card, with every failure ending in silence

Comparing versions without lying

The easy bug here is comparing version strings. As strings, "1.10" sorts before "1.9", so a person on 1.9 would never be told about 1.10. Every app uses the same numeric compare:

ts
export function compareVersions(a: string, b: string): number | null {
  const parse = (v: string) =>
    /^\d+(\.\d+)*$/.test(v.trim()) ? v.trim().split('.').map(Number) : null;
  const left = parse(a);
  const right = parse(b);
  if (!left || !right) return null; // not a dotted version: no card
  for (let i = 0; i < Math.max(left.length, right.length); i++) {
    const diff = (left[i] ?? 0) - (right[i] ?? 0);
    if (diff !== 0) return diff > 0 ? 1 : -1;
  }
  return 0;
}

A missing segment counts as zero, so 1.5 equals 1.5.0. Anything that isn't digits and dots returns null, which callers treat as "no update". The card only shows when the store is strictly newer. That also covers the build App Review and TestFlight testers run: it is newer than what the store sells, so the compare says "no".

Examples of the version compare rules
Numeric per segment; anything odd means no card

The other half of the compare is the installed version, and that is where a quiet bug could hide. If the constant in JavaScript drifts from Xcode's MARKETING_VERSION, people on the newest build get told to update. Two fixes, depending on the app:

  • Minivid, Linkeeper, Ringsy and Talkzy keep an APP_VERSION constant, and a unit test fails if it doesn't equal every MARKETING_VERSION in the Xcode project. Lockboxy does the same with its package.json version.
  • Baton and Stampzy skip the constant and read CFBundleShortVersionString from their native module, with a doc note: "never APP_VERSION in src/constants.ts, which can drift."

Not nagging

A prompt that appears too often gets ignored, or worse, gets the app deleted. The rules that keep it gentle are the same in all seven:

  • Never in the first session after install. The person just came from the store; there is nothing to nudge about. Minivid, Linkeeper and Lockboxy record firstSeenAt on the first check and compare it with when this JS runtime started. Ringsy and Talkzy keep a launchedBefore flag. Baton and Stampzy use "not yet onboarded when this session started".
  • Later means three days, for that version. SNOOZE_MS is three days and is tied to the store version that was snoozed. If an even newer version lands, the card shows again at once.
  • A card, never a sheet or a gate. It lives on one screen only, which by construction keeps it off onboarding, the paywall and anything in progress.
  • It waits its turn. Ringsy hides it while the first-run coach mark or What's New is up; Talkzy also while a rescued take is shown; Linkeeper while you're searching or selecting; Baton in expecting mode, when the contraction screen replaces Today.

Each card also follows its app's design rules. On Stampzy's Home the yellow belongs to "Open camera", so Update is a secondary button and Later a ghost one; a later Talkzy commit made Update secondary for the same reason: one primary button per screen.

Where the seven differ

The pure module is shared; the edges are not.

AppCard lives onInstalled versionNotes
MinividHomeAPP_VERSION + testUpdate opens the in-app App Store sheet first
Linkeepertop of LibraryAPP_VERSION + testhidden while searching or selecting
Lockboxymain vault Home, after unlockpackage.json + testthe lookup itself only runs after unlock
RingsyHomeAPP_VERSION + testconcurrent checks share one in-flight request
Talkzyscript libraryAPP_VERSION + testwaits for coach mark and What's New
BatonTodaynative CFBundleShortVersionStringno analytics at all
StampzyHome, under Open cameranative CFBundleShortVersionStringsecondary Update button

Lockboxy has the strictest rules. The card is allowed in exactly one place, the main vault's Home after unlock, and a test fails if any other file imports it. It never appears on the lock screen or the optional opening screen, the lookup only runs once the vault is open, and while it's up, the other Home tips wait so two cards don't compete for the same tap.

One difference worth knowing: what happens when a lookup comes back empty. Minivid, Linkeeper and Lockboxy keep the last good answer, so a flaky network never hides a real update. Ringsy and Talkzy clear the cached release when the lookup returns nothing, which errs toward hiding the card. The two behaviours should probably be made to match.

Opening the store differs too. Most apps try itms-apps://apps.apple.com/app/id<trackId> and fall back to the https URL. Minivid first tries the in-app App Store sheet, whose button already reads Update. Minivid, Linkeeper and Lockboxy switch the card off on Android explicitly: the lookup is Apple's, and there is no App Store listing to compare with.

Ten languages, three events, and how I test it

The strings are four keys (title, body, update, later) in all ten catalogs, with the version as a {{version}} placeholder, so the parity test catches a translation that drops it. In Vietnamese the card reads "Đã có phiên bản {{version}}", with "Cập nhật" and "Để sau".

Analytics are three events with no parameters at all: update_prompt_shown (once per store version per session), update_prompt_tapped, update_prompt_dismissed. No version, no region. Baton sends none, because its rules forbid analytics entirely.

Testing a feature that depends on a future App Store release needs a seam. Each app reads a debug-only launch argument: Minivid's -MinividUpdateInstalledVersion 0.0.1 pretends an old build is running against the live listing, while Ringsy's and Talkzy's -…UpdatePreview 9.9 pretend the store has 9.9 with no network at all. The seam also skips the daily throttle and the first-session rule. Maestro flows in English and Vietnamese use it to take the screenshots above. Release builds never read it.

Takeaways

  • You may not need a backend for this. Apple's lookup, a daily throttle and a cache-buster are enough.
  • Compare versions as numbers, and test where the installed version comes from. That second test is the one that stops you nagging people already on the latest build.
  • Gentle beats loud. No first session, a per-version snooze, one screen, silence on failure.
  • Port the pure module, decide the edges per app. Placement, styling and privacy rules belong to each app; the maths doesn't.

The same lookup also feeds my app showcase. For the rest of the setup these apps share, see shipping seven iOS apps solo.

  • #App Store
  • #React Native
  • #iOS
  • #UX
ShareXLinkedInFacebook
Dao Van Thuong

Mobile and fullstack engineer in Ho Chi Minh City. I build and ship my own indie iOS apps — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton and Stampzy.