The packaging is the easy part. Notes on the four things that broke after the wrapper worked: the launch screen, the back button, a service worker from a previous life, and the store forms.
Wrapping a working web application in Capacitor and submitting it to two app stores is presented as a packaging step. It is not. It is the point at which a set of assumptions your web app has been quietly making — about navigation, about caching, about who is looking at it — stop being true, and each one fails in a way that is invisible in a browser and obvious to a reviewer.
I have taken this route twice. What follows is the part nobody writes down: not how to run
npx cap add ios, which is documented, but the four things that broke afterwards and what
the fixes actually were.
An app of mine was rejected under App Store Review Guideline 3.2, which covers business model and, in the relevant part, apps that serve a single business rather than the general public.
The cause was one line of JavaScript. The shell launched, the web app booted, and a redirect sent anyone without a session to the sign-in screen. Sensible behaviour for a SaaS product on the web. Fatal in review, because the reviewer opened the app on an iPad, saw a login form, could not get past it, and concluded — reasonably, on the evidence in front of them — that the app existed to serve one organisation's staff rather than the public.
Nothing about the product was wrong. The first screen was wrong. And the first screen is the only evidence a reviewer has.
The fix was to make the launch route a genuinely public page: categories browsable, listings readable, search usable, all with no account. A signed-in user is one tap away — the header swaps its sign-in link for an account link based on a local flag — but nothing bounces anyone anywhere. The principle generalises past app review, and it is the thing worth taking from this:
If your app cannot show a stranger something useful before it asks them for anything, it will read as a private tool. To a reviewer, and also to every user who downloaded it out of curiosity and will never come back.
Two practical notes for anyone in the same position. A redirect shipped in a native binary lives in every installed copy until the user updates, so a fix that lives in the web layer rather than the native shell fixes the builds already on devices too — an argument for keeping this kind of logic web-side. And test the launch on a tablet, in a fresh install, signed out, which is not how any developer ever opens their own app.
A native app has a back button. A web app inside a native shell has whatever you draw. And the naive version of drawing it is wrong in a way that is worse than not drawing it at all.
The version I shipped first drew both arrows all the time, called history.back(),
checked whether the location had changed, and when it had not, sent the user to the site root. So
tapping "back" on the first screen took you somewhere you had never been. A control that lies about
what it does is worse than an absent one, because the person taps it expecting to return and lands
somewhere they did not ask for.
The rule I hold to now: an arrow exists only while it leads somewhere. No back arrow with no prior screen, no forward arrow with nothing ahead. And not disabled and greyed out as a consolation — absent.
Deriving that is harder than it should be, because neither piece of information is available.
history.length counts entries from before your app existed and never decreases. And
there is no canGoForward outside the Navigation API, which WebKit does not ship, which
means iOS does not have it.
So you keep the bookkeeping yourself. Stamp each history entry with an index in
history.state, keep the highest index reached in sessionStorage, and derive
both arrows from those two numbers: back when the current index is above zero, forward when it is
below the highest reached.
function mark(replace) {
const i = (history.state?.i ?? 0) + (replace ? 0 : 1);
history[replace ? 'replaceState' : 'pushState']({ ...history.state, i }, '');
// A new push truncates whatever was ahead: pull the ceiling down to it,
// or the forward arrow becomes a lie.
const top = Number(sessionStorage.getItem('navTop') ?? 0);
sessionStorage.setItem('navTop', String(replace ? top : i));
return i;
}
That last comment is the subtle one. Pushing a new entry destroys the forward history, so the ceiling has to come down at the same moment, or the forward arrow stays drawn pointing at a stack entry that no longer exists.
This is the case that actually broke it in the simulator, and it is not a theoretical one.
Navigating back to a full page load restores the document from the back-forward cache. The page is
not re-executed. No popstate fires. Your arrows keep whatever state they had when the
document was frozen, which is usually wrong.
Listen for pageshow and recompute there as well. It fires on both a fresh load and a
bfcache restore, and event.persisted tells you which. Anything deriving UI state from
history has to recompute on that event or it will be stale exactly when the user is navigating
fastest.
One layout note that costs nothing and is noticed immediately if you skip it: leave an inert, same-size slot where a missing arrow would be. An empty span, not a disabled button — nothing to tap and nothing for a screen reader to announce. Otherwise back drifts leftward as forward disappears, and the toolbar changes height as arrows come and go.
If the app ever shipped as something else — in my case a Flutter build that registered its own service worker — that worker is still installed on every returning visitor's device, and it will keep serving the old cached shell after you have replaced the entire application.
This produces the worst class of bug report: a user insisting the app is broken while it works perfectly for everyone else, including on your machine, because your machine never had the old version.
The cleanup has to be defensive and it has to run before anything else:
if ('serviceWorker' in navigator) {
// Stop anything re-registering one.
try { navigator.serviceWorker.register = () => Promise.reject('sw disabled'); } catch (e) {}
navigator.serviceWorker.getRegistrations().then(regs => {
const had = regs.length > 0;
regs.forEach(r => r.unregister());
const cleared = window.caches
? caches.keys().then(k => Promise.all(k.map(x => caches.delete(x))))
: Promise.resolve();
// Reload once, and only once, or a device with a stale worker loops.
if (had && !sessionStorage.getItem('sw_cleared')) {
sessionStorage.setItem('sw_cleared', '1');
cleared.then(() => location.reload());
}
}).catch(() => {});
}
The session flag is the part people leave out and then debug for an afternoon. Unregistering and reloading unconditionally gives you a device that reloads for ever. The flag makes it happen exactly once per session, which is what you meant.
The engineering is close to identical. The submission is not, and the difference is mostly about what each reviewer is looking for.
Apple reviews the product. A human opens it and forms a view about what it is for. That is why the launch screen decided my rejection and why the fix was a product decision rather than a configuration change. Budget for a rejection on the first submission of anything unusual, and read the guideline they cite rather than guessing — the citation is specific and it tells you what they actually saw.
Google reviews the declarations. The Data Safety form is the part that bites, because it must match what the app genuinely does, and it is checked against observed behaviour. If an SDK you added for something unrelated collects an identifier, that has to be declared. Get this wrong and the rejection is about a form rather than about the app, which is easier to fix and easier to get wrong repeatedly.
Both want the same three things and both will hold a release over any of them: a working account deletion path reachable from inside the app, a privacy policy at a live URL that describes what is actually collected, and permission strings that say why you want the permission rather than that you want it. Camera access explained as "this app uses the camera" is a rejection. Explained as "to attach a photo to a job you are posting" it is not.
On account deletion specifically: it must be as easy as signing up, and it must be in the app, not an email address you promise to read. Build it before submission rather than after the rejection.
Honestly: it depends on what the app does, and the answer is not always yes.
It is right when the product is fundamentally forms, lists, records and messaging — which is most business software. One codebase, one deployment, fixes reaching every platform simultaneously, and web-layer changes reaching installed builds without an app store round trip. For a small team that last point alone can be decisive.
It is wrong when the product's value is in sustained interaction with the device: heavy graphics, continuous background location, real-time audio or video processing, anything where a frame budget matters. You will spend more time fighting the bridge than you saved by not writing native code.
The honest failure mode is the middle: an app that is mostly forms with one demanding feature. That one feature ends up as the native plugin you maintain by hand, in a language the rest of the team does not read, and it accumulates the platform-specific bugs the whole approach was meant to avoid. If you can see that feature coming, decide about it at the start rather than discovering it in month four.
What has held up across both apps is the smaller claim rather than the grand one. Not that cross-platform is free — it is not — but that the things which actually cost time were never the rendering. They were the launch screen, the back button, a cached worker from a previous life, and a form about data collection. All four of those are the same shape of problem: an assumption the web version was allowed to make, meeting a platform that does not share it.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.