◉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, 35 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, 35 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/App Dev
◇ App Dev/2026-10-26Advanced

Your 5% Rollout Says 99.76% Crash-Free. How Many Users Until You Can Trust It?

At 5% rollout, a crash-free rate rests on few users and the interval is wide. A Wilson-interval script that says proceed, wait or halt, plus simulated numbers on how often you halt a good release.

staged rolloutcrash-freeCrashlytics13Google Play43Wilson intervalrelease decisionsPythonindie development43

✦ Premium Article

The morning after I moved a Google Play staged rollout to 5%, Crashlytics showed "Crash-free users 99.76%". My bar is 99.7%. Above the bar, so on to 25% — and then my hand stopped, because the user count read 420.

The same percentage means different things at different sizes. 99.76% over 420 users may simply mean only one person happened to crash. 99.76% over 20,000 users is close to settled. The dashboard prints both in the same typeface with the same confidence.

As an indie developer I ship in four steps, 5% → 25% → 50% → 100%, and I halt (pull the fraction back) when crash-free users drop below 99.7% or ANR rises above 0.20%. I wrote about this in maintaining my Android wallpaper apps after a release; v2.0.0 was halted twice. I am not changing those lines. What I changed is that I now look at how far the number can be trusted, next to the number.

Below is a small Python script that decides proceed, wait or halt with sample size included, and an unexpected side effect of running it every morning, which I measured by simulating it.

The same 99.76% reads very differently at different sizes

Crash-free is binomial data: k of n users crashed. The observed value can stay the same while the plausible range around it changes a lot with n. Here is the 95% Wilson interval (explained below) for four sizes.

UsersCrashedObserved crash-freePlausible true value (95%)Verdict (target 99.7%)
420199.76%98.66-99.96%Wait
2,000599.75%99.42-99.89%Wait
5,0001299.76%99.58-99.86%Wait
20,0004899.76%99.68-99.82%Wait

All four rows sit above 99.7% as observed. Even at 20,000 users the bottom of the interval, 99.68%, still dips under the target. "It's above the bar, go" holds far later than I expected.

Whatever the first 5% shows is a direction, not a verdict. If you want to use it as evidence, decide in advance what counts as enough evidence.

Decide by where the interval sits relative to the limit

The rule is simple. Look at the interval against the allowed crash rate (0.3% for a 99.7% target).

  • If even the upper end is within the limit, it is clearly good: proceed.
  • If even the lower end exceeds the limit, and the count is more than noise, it is clearly bad: halt.
  • If the interval straddles the limit, you cannot tell yet: wait.

This runs as-is. It uses only the standard library; you type in the user count and crashed-user count that Crashlytics shows.

#!/usr/bin/env python3
"""Decide PROCEED / WAIT / HALT for a staged rollout, sample size included.
Usage: python3 rollout_gate.py --users 420 --crashed 1 --target 99.7
Standard library only. Type in the user count and the crashed-user count
shown in Crashlytics / Play Console."""
import argparse
import math
import sys
 
Z = {90: 1.645, 95: 1.960}  # one-sided 95% = 1.645 / two-sided 95% = 1.960
 
 
def wilson(k: int, n: int, z: float) -> tuple[float, float]:
    """Wilson interval (low, high) for crash rate k/n. Stays inside 0..1 even for small n."""
    if n <= 0:
        raise ValueError("users must be > 0")
    p = k / n
    z2 = z * z
    denom = 1 + z2 / n
    center = (p + z2 / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / denom
    return max(0.0, center - half), min(1.0, center + half)
 
 
def judge(users: int, crashed: int, target_pct: float, z: float = Z[95],
          min_crashed: int = 3) -> dict:
    allowed = 1 - target_pct / 100          # allowed crash rate (99.7 -> 0.003)
    low, high = wilson(crashed, users, z)
    if low > allowed and crashed >= min_crashed:
        verdict = "HALT"                    # even the lower bound exceeds the limit, and the count is not just noise
    elif high <= allowed:
        verdict = "PROCEED"                 # even the upper bound is within the limit = clearly good
    else:
        verdict = "WAIT"                    # interval straddles the limit = cannot decide yet
    return {
        "users": users, "crashed": crashed,
        "observed_crash_free": 100 * (1 - crashed / users),
        "crash_free_low": 100 * (1 - high), "crash_free_high": 100 * (1 - low),
        "verdict": verdict,
    }
 
 
def users_needed_for_zero_crash(target_pct: float, z: float = Z[95]) -> int:
    """From which user count can we say PROCEED with zero crashes?
    For k=0 the Wilson upper bound is z^2/(n+z^2); solve <= allowed for n."""
    allowed = 1 - target_pct / 100
    return math.ceil(z * z * (1 / allowed - 1))
 
 
def days_to_decide(users: int, needed: int, users_per_day: float) -> int:
    """Days until we reach `needed` users. Pass the recently measured number of
    new users per day who open the new version at this stage."""
    if users >= needed:
        return 0
    if users_per_day <= 0:
        raise ValueError("users_per_day must be > 0")
    return math.ceil((needed - users) / users_per_day)
 
 
def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--users", type=int, required=True)
    ap.add_argument("--crashed", type=int, required=True)
    ap.add_argument("--target", type=float, default=99.7)
    ap.add_argument("--per-day", type=float, default=0,
                    help="measured new users per day on the new version at this stage")
    ap.add_argument("--min-crashed", type=int, default=3,
                    help="below this crash count, answer WAIT instead of HALT")
    ap.add_argument("--conf", type=int, choices=sorted(Z), default=95)
    a = ap.parse_args()
    if a.crashed > a.users:
        print("crashed exceeds users", file=sys.stderr)
        return 2
    r = judge(a.users, a.crashed, a.target, Z[a.conf], a.min_crashed)
    print(f"{r['verdict']}: {r['crashed']}/{r['users']} users crashed "
          f"(observed {r['observed_crash_free']:.2f}% / true value within "
          f"{r['crash_free_low']:.2f}-{r['crash_free_high']:.2f}%)")
    if r["verdict"] == "WAIT":
        need = users_needed_for_zero_crash(a.target, Z[a.conf])
        print(f"  Confirming {a.target}% with zero crashes takes about {need} users")
        if a.per_day > 0:
            print(f"  At the current pace: about {days_to_decide(a.users, need, a.per_day)} more days")
    return {"PROCEED": 0, "WAIT": 10, "HALT": 20}[r["verdict"]]
 
 
if __name__ == "__main__":
    sys.exit(main())

I chose the Wilson interval because the usual normal approximation breaks exactly where we live: small n, zero to a handful of crashes. At k = 0 the normal approximation has zero width, which produces the false confidence of "no crashes, so perfectly safe". Wilson keeps a width at k = 0 and never leaves the 0 to 1 range. The exit codes 0, 10 and 20 let a shell script or CI job branch on the verdict.

Running it:

$ python3 rollout_gate.py --users 420 --crashed 1
WAIT: 1/420 users crashed (observed 99.76% / true value within 98.66-99.96%)
  Confirming 99.7% with zero crashes takes about 1277 users
 
$ python3 rollout_gate.py --users 2000 --crashed 14
HALT: 14/2000 users crashed (observed 99.30% / true value within 98.83-99.58%)
✦

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 can now calculate, from your own target, the smallest user count at which a 5% stage crash-free rate justifies moving on
✦You will be able to stop halting good releases on a noisy number by adding a minimum crash count to your halt rule
✦You can estimate how many days remain until a decision is possible, and check the dashboard less often
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-09-10
Your First EAS Workflow in a Rork Repo, and the Alert That Goes Missing Exactly When You Need It
Putting two files into .eas/workflows in a repo exported from Rork, and why a notification wired with needs stays silent on exactly the nights it fails — with the output of a small local checker I actually ran.
◇ App Dev2026-09-09
Those 14 Days of Closed Testing Are for Gathering What the Production Form Asks
Google Play production access starts from a closed test with 12 testers opted in continuously for 14 days. Here is how the count actually works, the order the tracks open in, what the application form asks, and the records worth keeping during those two weeks.
◇ App Dev2026-09-07
Sending user text to an external AI: what actually goes in Play's Data safety form
Classifying user input sent to an external AI as collected, shared, or ephemeral in Play's Data safety form, plus a redaction ordering bug found by running the code.