Shipping a mobile app in Nepal: the part that starts after launch
· 7 min read
When someone commissions an app, the mental model is usually construction: you agree a scope, it gets built, it's handed over, it's done. That model is wrong in a way that costs real money, and it's the single most common misunderstanding I run into as a mobile app developer in Nepal. Launch isn't the end of the build. It's the moment your app starts accruing obligations to two app stores, a fleet of devices you can't inspect, and a payment gateway that will eventually disagree with you about whether a customer paid.
You don't control your own release calendar
Google Play raises its target API level floor every year, and it applies to updates, not just new apps. As of 31 August 2026, new apps and app updates for phones and tablets must target Android 16 (API level 36) or higher, and existing apps must target at least Android 15 (API level 35) to stay available to new users on newer devices. Play offered extensions to 1 November 2026.
Read that as a business fact rather than a technical one: an app with a hundred thousand installs cannot ship a one-line crash fix until its targetSdk is current. Each bump brings behaviour changes — background execution, permissions, storage, notification posting — that you test for rather than just recompile against. If nobody is budgeted to do that, your app has a quiet expiry date. Apps quietly stop reaching new users every year for exactly this reason, not because anything broke.
A second calendar problem hits new apps. Personal Play developer accounts created after 13 November 2023 can't publish to production until at least 12 testers stay opted in to a closed testing track for 14 consecutive days — continuous means continuous, so if a tester drops out and rejoins, their clock restarts. That's weeks between "the app works" and "the app is downloadable", and it belongs on the timeline before anyone promises a launch date.
A release is one-directional
Web deploys are reversible. Mobile releases aren't, and that changes how you ship. Staged rollouts are the main safety mechanism: you release to a percentage of users and raise it over time. If something is wrong, you halt the rollout, which stops additional users receiving that version. What it does not do is take the version back — Play's own documentation is explicit that users who already received it stay on it. Halting limits the blast radius. It does not undo the damage, and the only real fix is a higher version code shipped forward.
Which is why the forced-update path has to exist before you need it. Play's in-app updates API carries an update priority from 0 to 5; at high priority you can present an immediate, blocking, full-screen update the user must accept. Two details decide whether that's available to you on a bad day:
- Priority is set via the Google Play Developer API — the
inAppUpdatePriorityfield underEdits.tracks.releases. You cannot set it in the Play Console UI, which means it has to be part of your release automation, not a checkbox someone remembers. - It can only be set when you roll out the release, and cannot be changed afterwards. You can't escalate yesterday's release to urgent. You get one chance, at publish time, to declare how badly users need this build.
The client-side handling also has to already be in the version people are running. An app that never learned to check updatePriority() can't be told to force-update; you're reduced to hoping auto-updates reach people. That's the trap when you ship a breaking API change — your server moves on and the old clients don't know they should care. The honest answer is to not get there: version the API, keep the old contract alive while the install base drains, and treat a forced update as the emergency lever rather than the migration plan.
Every capability you might need in an incident has to be shipped before the incident. A forced-update path, a remote kill switch for a broken feature, a way to point the app at a different endpoint — none of those can be added to a build that's already on the phone.
Payments: the client is a witness, not the record
Payment bugs generate the angriest messages, and almost all of them come from trusting the wrong thing. The user pays in eSewa or Khalti, gets redirected back, and the app marks the order paid based on that redirect. Then a redirect gets lost — connection drops, user kills the app, browser handoff fails — and you have money moved with no order, or an order with no money.
The record of truth is the gateway, reached from your server. eSewa's ePay v2 documents a status-check endpoint taking product_code, total_amount and transaction_uuid, returning states you have to actually handle: COMPLETE, PENDING, CANCELED, NOT_FOUND, FULL_REFUND, PARTIAL_REFUND, and AMBIGUOUS for a transaction in a halt state. eSewa also tells you to verify the signature on the response body rather than taking the payload at face value. Khalti works the same shape — you hold a pidx and your backend calls the ePayment lookup endpoint to find out what really happened.
So reconciliation belongs on the Django backend: verify the signature, call the status API, confirm the amount matches what you charged, then transition the order. Treat a payment as a state machine with a pending state a background job resolves, because PENDING and AMBIGUOUS aren't errors — they're the gateway telling you to ask again later. And make the flow idempotent with your own transaction identifier, so tapping "pay" twice on a flaky connection can't produce two charges.
None of that logic can live in the app. A client can be uninstalled mid-flow, and it has every incentive to be wrong.
Push notifications are best-effort, and the mid-range Android market makes that worse
FCM is not a delivery guarantee, and the documentation doesn't pretend otherwise. Messages have a time to live — up to 2,419,200 seconds, 28 days, and four weeks by default — and if the device never reconnects the message is discarded. Set a collapse_key and a newer message replaces the pending one entirely; you also only get four distinct collapse keys at a time. High priority buys a wake from Doze, but it's not a blank cheque: Firebase documents that if FCM detects a pattern of high-priority messages that don't produce a user-facing notification, your messages get deprioritised to normal, assessed over roughly seven days of behaviour.
Then there's the layer that isn't in any Google document. Around three quarters of mobile traffic in Nepal is Android, skewed heavily toward mid-range devices from manufacturers who ship their own battery manager on top of AOSP. Xiaomi, Oppo, Vivo and Realme skins are meaningfully more aggressive than stock — per-app autostart permissions off by default, periodic sweeps that kill background processes, apps frozen after a day or two of not being opened. It's documented well enough that there's a whole project cataloguing it by vendor. You can't fix it from your code.
What you can do is design so a dropped notification isn't data loss. Never make a push the only way a user learns something — if it matters, it has to be in the app state when they open it, fetched fresh. Use notifications to prompt a sync, not to carry the payload. For anything time-critical, like an order status, reconnect on foreground rather than trusting that a message arrived. And measure delivery instead of assuming it: "we sent it" and "they saw it" are different numbers.
Testing on a flagship tells you almost nothing
A developer on a recent flagship with good WiFi will not reproduce the bugs your users have. Different OS version, different vendor skin, a quarter of the RAM, a screen that makes your layout overflow, and a network that drops packets — I wrote about that last one in Flutter on a bad connection. The interesting failures live on the devices that dominate the market here.
This isn't only a quality argument — Google scores you on it. Android vitals sets bad-behaviour thresholds on core metrics: a 1.09% user-perceived crash rate and a 0.47% user-perceived ANR rate overall, plus an 8% threshold on each for an individual phone model, assessed over 28 days. Exceed them and Play may reduce your app's visibility in search and browse, and may show a warning on your store listing. A crash confined to one popular mid-range handset is enough to trip the per-model threshold, and the per-model view is how you find it.
So: crash reporting from day one, with builds symbolicated so the stack traces are readable. Watch vitals per device model, not only in aggregate. Triage on a real cheap phone.
What this means if you're hiring an app developer in Nepal
You're not buying a build. You're buying an ongoing relationship with two app stores, and it should be priced that way. A few questions separate people who've maintained apps from people who've only launched them:
- Who bumps the target API level next year, and is it in scope? If the answer is vague, you have an app with a shelf life.
- How do we force an update if a release is broken? The right answer mentions staged rollouts, that halting doesn't reverse anything, and a mechanism that's already in the shipped app.
- Where does payment verification happen? If any part of the answer is "in the app", keep asking.
- What devices was this tested on? Model names, not "Android and iOS".
- Who watches the crash rate, and how? If nobody does, the first report arrives as a one-star review.
I've worked on Flutter apps that have passed 50,000+ combined downloads on the Play Store, and led mobile end to end at Sangatha — architecture, features and the release pipeline. The pipeline is the part people underestimate. Making a release boring is worth more than any single feature, because it's what lets you fix things quickly when a Tuesday goes wrong.
More on how I approach Flutter and mobile development, the CI/CD and monitoring behind it, and where I've done this.