DEV Community

HS Solution
HS Solution

Posted on Fully Autonomous

Why our Flutter in-app purchases got stuck on "Loading" (and the retry design that fixed it)

In our Flutter app (Android and iOS, in_app_purchase 3.3.1) we shipped a bug where the shop's price list stayed on "Loading" forever. The cause was simple: if connecting to the store or querying products failed once, nothing ever tried again until the user restarted the app.

Here is what was wrong, and the retry design we replaced it with.

The app's home screen, from the Google Play listing

What was going on

All billing code lives in a Billing singleton with three states: loading, ready and unavailable. The old refresh() looked roughly like this:

Future<void> refresh() async {
  final gw = _gw;
  if (gw == null || status.value == BillingStatus.loading) return; // not connected, or busy: drop it
  status.value = BillingStatus.loading;
  try {
    if (!await gw.isAvailable().timeout(const Duration(seconds: 15))) {
      status.value = BillingStatus.unavailable; return; // one failure ends it
    }
    final q = await gw.query(ProductIds.all).timeout(const Duration(seconds: 20));
    _products = {for (final e in q.found.entries) e.key: e.value}; // full overwrite
  } catch (e) { status.value = BillingStatus.unavailable; } // no retry
}
Enter fullscreen mode Exit fullscreen mode

That has four holes:

  1. A failed connection at startup is permanent. If creating the gateway threw at launch, _gw stayed null, and every later refresh() returned on the first line.
  2. Requests during a query were dropped. A refresh that arrived while loading was simply ignored.
  3. No automatic retry. isAvailable() == false, a timeout and an exception all ended in unavailable. The only retry was opening the shop screen.
  4. Partial results overwrote good data. A query that returned only some products could erase products we already had.

Temporary store failures are common: the Play Store app updating itself, a network blip. The real problem was that one failure meant "Loading" forever.

The fix

1. Reconnect on the next attempt

If the connection fails at startup we don't give up. ensureStarted() reconnects on every new attempt, and syncs past purchases once it connects.

2. Don't drop requests during a query — queue one more

Future<void> refresh() {
  final cur = _inflight;
  if (cur != null) { _again = true; return cur; } // run once more after this one
  // ...
}
Enter fullscreen mode Exit fullscreen mode

However many times it is called, at most one extra query runs after the current one.

3. Merge results instead of overwriting

_products = {..._products, ...found}; // a partial answer can't erase known products
Enter fullscreen mode Exit fullscreen mode

4. Retry automatically at 5 s → 30 s → 2 min, then stop

static List<Duration> retryDelays =
    const [Duration(seconds: 5), Duration(seconds: 30), Duration(minutes: 2)];

void _afterRefresh() {
  if (!needsRetry) { _retryStep = 0; return; } // got everything: reset
  if (_retryStep >= retryDelays.length) return;  // stop after 3
  _retryTimer = Timer(retryDelays[_retryStep++], () => unawaited(refresh()));
}
Enter fullscreen mode Exit fullscreen mode

We cap it on purpose. On devices where the store really is unavailable (no Play Store, for example), retrying forever only burns battery and data. Timeouts stay at 15 s for the connection check and 20 s for the query.

5. Let user actions and app resume trigger a retry

If the three automatic tries fail, four other things trigger a retry: app resume, opening the shop, pressing a buy button, and a manual retry.

// main.dart
AppLifecycleListener(onResume: Billing.i.onResume);

// billing.dart
void onResume() { if (needsRetry) unawaited(retryNow()); } // only when products are missing
Enter fullscreen mode Exit fullscreen mode

We don't listen to a connectivity stream. Timers handle the automatic part; everything else comes from the user or from resume. A manual trigger resets the retry schedule to zero. If the buy button is pressed while billing isn't ready, it calls retryNow() and then continues straight into the purchase.

6. Stop saying "Loading"

We split failures into connect, timeout and notFound, and the screen now says "Connecting to the store… / Retry" instead of "Loading". With "Loading", users can't tell whether waiting will help or whether the item is gone. The first time each failure type appears we log an analytics event, so we can see later which failures are common.

Takeaways

  • Write billing setup so it can be redone any number of times, not "once at startup".
  • Don't drop requests that arrive mid-query; queue exactly one more.
  • Merge query results so a partial answer can't delete known products.
  • Cap automatic retries; leave the rest to app resume and user actions.
  • Don't show a vague state like "Loading" when you know what failed.

This shipped in version 1.1.1 on Android and iOS. We haven't yet verified every real-payment path in production, so if your setup is similar, try it with a Play Console license tester: toggle airplane mode and force-stop the Play Store app while the shop is open.


Disclosure: I'm the developer of this app, **Starlit Alley: Tarot & Saju* (HS Solution). Core readings (saju, tarot, dream reading) are free, with no sign-up and no ads. Google Play*

This post was drafted by an AI agent from our repository and fix notes. The code and numbers are quoted from the actual repository.

Top comments (1)

Collapse
 
hssolution profile image
HS Solution •

If you want to see the shop screen this fixed, the app is free on Google Play (no sign-up): https://play.google.com/store/apps/details?id=com.noligo.saju_app&referrer=utm_source%3Ddevto%26utm_medium%3Dcomment%26utm_campaign%3Dbilling-retry

Question for other Flutter devs: do you retry queryProductDetails on a timer like this, or only on app resume / user action?