All chapters Part IV · Building
Chapter 18

Mobile Development

"Feels native" is not a vague quality. It's about six specific details, five of which are cheap. The sixth is where you lose two days to a platform behaviour that operates below your code and never tells you it exists.


The concept

Mobile differs from web in ways that are easy to underestimate:

Choosing an approach

ApproachRework from webNative feelBest forReal cost
Native (Swift / Kotlin)Two rewritesPerfectCamera, AR, background work, heavy gesturesTwo codebases forever
React Native / ExpoOne rewriteVery goodMost consumer apps built freshBridge quirks; some native work anyway
FlutterOne rewrite (Dart)Very goodConsistent UI across platformsOwn rendering; less "platform-native"
Capacitor / webview~NoneGood for content, poor for gesturesExisting web app; text/content productsWebView quirks; no native scroll physics
PWA onlyNoneNo store presence,No IAP, no store discovery

Prototype your hardest interaction before committing. If your product lives on a gesture-driven canvas or a 60fps timeline, build that first in your chosen approach. You'll know within a day.

The six details that make an app feel native

  1. Safe areas. Content must not sit under the notch, Dynamic Island, status bar, or home indicator. Use the platform's safe-area insets, and pair them with a minimum, because on devices without a notch the inset is zero and your content jams against the edge.
  1. Momentum scrolling and overscroll containment. Native scroll physics, and scrolling inside a panel must not drag the page behind it.
  1. Correct viewport units. On mobile web/webviews, 100vh is the height with browser chrome hidden, so content is taller than the visible area and the layout jumps as toolbars show and hide. Use svh/dvh.
  1. Haptics. One line per interaction, and the app feels physical. The highest ratio of perceived quality to effort in mobile.
  1. Correct scroll restoration. Screens must open where the user expects. This is the one that costs days, see the case study.
  1. Eased motion. 200-400ms, ease-out. Never linear, never bouncy unless it's your brand.

Plus the hygiene: disable tap-highlight, make UI chrome non-selectable, and reset state on every screen entry.

Platform conventions differ, genuinely

iOSAndroid
BackSwipe from left edge; no system back buttonSystem/gesture back, must be handled
NavigationBottom tab barBottom nav or top app bar
SharingShare sheetIntents
DialogsAction sheetsBottom sheets, snackbars
TypographyDynamic TypeScalable sp units

Not handling Android's back button is the single most common cross-platform bug. Users press back and your app closes.

The simulator lies

Works on simulatorNeeds a real device
Layout, logic, most flowsHaptics
Most plugin wiringSign in with Apple / platform auth
Basic navigationReal purchases (sandbox behaviour differs)
Push notifications
Honest performance
Camera, sensors, biometrics

Keep a running list titled "not yet proven on hardware." Every time you write "verified" with a caveat, add a line. That list is your device-testing plan, and it writes itself.

App size matters commercially

Large binaries mean slower installs, cellular-download limits, and abandonment on the store's size line. Bundle what's needed for first use; download the rest on demand with a local cache.


📐 Best practice

Prototype the hardest interaction first.

Handle safe areas on all four sides, each with a minimum floor.

Use svh/dvh, never vh in webviews.

Add haptics early. Wrap them so they're a silent no-op where unavailable, and callers never need to guard.

Reset state on every screen entry, scroll position, animations, transient UI.

Handle Android back explicitly.

Request permissions in context, never on launch (Chapter 10).

Test on the oldest device you support, on a bad network.

Comment hard-won platform fixes emphatically. A future developer will "simplify" them otherwise.

Script your build so it's one command, and make it fail loudly (Chapter 33).


💀 Common mistakes

Content under the notch, or a CTA under the home indicator.

100vh in a webview, layout jumps as toolbars move.

No haptics. Free perceived quality, left on the table.

Screens opening at the wrong scroll position.

Ignoring Android back.

Permission prompts on launch, denied, often permanently.

Testing only on a flagship simulator. Hides performance, haptics, auth and purchase behaviour.

Assuming the app stays alive. The OS kills backgrounded apps; persist state.

Shipping an enormous binary and losing installs to the size line.

Fighting platform conventions because you prefer your own.


The professional workflow

 1. CHOOSE APPROACH by rework cost + hardest interaction

 2. PROTOTYPE THE HARDEST INTERACTION on a real device

 3. SET UP THE BUILD as one command; make it fail loudly

 4. IMPLEMENT THE SIX DETAILS
    safe areas · momentum scroll · svh/dvh · haptics ·
    scroll restoration · eased motion

 5. HANDLE PLATFORM DIFFERENCES
    Android back · share · dialogs · dynamic type

 6. STATE + LIFECYCLE
    persist across kill · restore on resume · reset on screen entry

 7. PERMISSIONS IN CONTEXT

 8. MAINTAIN THE "NOT PROVEN ON HARDWARE" LIST

 9. TEST on oldest supported device, throttled network, largest text

10. OPTIMISE BINARY SIZE before submission

Tools, websites & costs

NeedToolCost
iOS buildsXcode (Mac required)$0 (Mac ~$599+)
Android buildsAndroid Studio$0
Cross-platformReact Native, Expo, Flutter, Capacitor$0
Developer accountsApple $99/yr · Google Play $25 once$124 year one
Cloud Mac (no Mac)MacStadium, Codemagic$25-100/mo
Device testingFirebase Test Lab, BrowserStackFree tiers
Crash reportingSentry, CrashlyticsFree tiers
DistributionTestFlight, Play internal testingFree
CI/CDFastlane, EAS, GitHub Actions$0-99/mo
Debugging webviewsSafari Web Inspector, Chrome DevTools$0
Icon/splash generation@capacitor/assets, Expo assets$0

A Mac is mandatory for iOS. If you don't own one, a cloud Mac is cheaper than buying one for a first app.


Alternatives & trade-offs

One codebase vs two. Cross-platform saves roughly half the work and costs some platform polish. Native wins when the product is the platform integration. Most consumer apps should go cross-platform.

Ship both platforms vs one first. One first, almost always, half the store surface, half the QA, half the support, and you learn before doubling down. Choose the platform where your customer pays (Chapter 37).

Expo managed vs bare. Managed is dramatically faster until you need a native module it doesn't support; the ejection path is real work. Start managed.

Webview vs native rendering. Webview is right for text and content, wrong for gesture-heavy or animation-heavy products. Know which you're building before you commit.

OTA updates (Expo Updates, CodePush) let you fix JS without a store review, a genuine advantage. Constrained by store policy: you can't change the app's fundamental purpose this way.


Checklist


📓 Case Study: two days lost to a behaviour below the code

Project: SOLIS, a web app wrapped in a WebView for iOS.

The cheap details, done well. Safe areas took two rounds, "the app is getting merged with ios top UI", then "its too low now, make it slightly bit above", which is normal; you cannot get this right by reasoning, only by looking at a device. The implementation is worth copying:

padding: max(24px, env(safe-area-inset-top) + 8px)
         calc(22px + env(safe-area-inset-right))
         calc(40px + env(safe-area-inset-bottom))
         calc(22px + env(safe-area-inset-left));

max(24px, env(…)), not raw env(). On a device with no notch the inset is 0 and content jams against the edge. The max() guarantees a floor, and all four sides are handled, including the bottom, which is where CTAs live.

Haptics were wrapped so they silently no-op in a browser or without the plugin, "so callers never need to guard", the right shape for any platform-optional capability.

💀 The expensive detail: two days, five failed fixes.

Symptom: every lesson opened at the previous lesson's ending, at the bottom, skipping all the content.

The fixes tried, all reasonable:

  1. window.scrollTo(0,0) after render → no effect
  2. Same, inside requestAnimationFrame → no effect
  3. scrollTop = 0 on the container → no effect
  4. A temporary CSS scroll lock → no effect
  5. All of the above together → no effect

Five plausible fixes, zero progress, and a user getting understandably frustrated:

"the fix for open first page in every lesson did not work" "its still not working and its stutering as well… are you sure you synced the ios app?"

The question that cracked it:

"check if we are using something common for all the lesson, issue must be there"

Root cause: iOS WKWebView silently restores the document's native scroll offset when content is swapped. Not a JavaScript scroll position, the WebView's own behaviour, operating below the JS layer, firing after your code has run, with no scroll event to hook.

The "something common" was the document itself. Every lesson rendered into the same document, and the document remembered where it had been.

1. User scrolls lesson 1 to the bottom
2. User navigates back
3. User opens lesson 2
4. JS swaps content, sets scrollTop = 0        ← your code, correct
5. WKWebView restores the document's offset    ← native, silent, LATER
6. Lesson 2 appears at the bottom

You cannot win step 5 by trying harder at step 4.

The fix was architectural, not a patch. If the document never scrolls, there's nothing to restore. The reader was moved into an inner scrolling container, with the document locked to viewport height. WKWebView has no restore behaviour for arbitrary inner scrollers, so container.scrollTop = 0 finally became authoritative.

The comment left in the CSS is deliberately emphatic, because this is exactly the kind of fix someone later "simplifies":

THE READER SCROLLS IN AN INNER CONTAINER, never the document. iOS WKWebView restores the DOCUMENT's native scroll offset silently (no scroll event) when we swap content, that is what reopened every lesson at the previous one's ending, and no amount of window.scrollTo(0) could hold it.

Four transferable lessons:

  1. When five reasonable fixes fail, your model is wrong. Stop generating variations, they're all correct given a wrong model.
  2. "What's common across the failing cases?" is the highest-yield debugging question there is.
  3. Platform layers act below your code, after your code, silently. WebView scroll restoration, browser bfcache, autofill, keyboard handling, none of them announce themselves.
  4. When the platform fights you, move rather than push. Put the behaviour where the platform's behaviour doesn't apply.

The diagnosis came from driving the app and measuring, not from reading code. The code was correct throughout.

⚠️ Deviation: device-only features were never tested on a device.

The status file was honest about it:

⚠️ Not yet provable on a Simulator: Sign in with Apple needs a real device with an Apple ID, so both providers still need a TestFlight pass before launch.

Five features, Apple sign-in, Google sign-in, real purchases, haptics, notifications, were implemented, reviewed, mock-tested, and never once executed on real hardware. Any could fail in a way no amount of code review would reveal.

Keeping that list is genuinely good practice. Not clearing it before calling the product submittable is the deviation.

⚠️ A second, quieter one: the app bundled 525 MB of images. Defensible for offline reliability, and an install-conversion problem that was never addressed (Chapter 17).


Lessons

  1. "Feels native" is six details. Five are cheap; one can cost days.
  2. Pair env() insets with a minimum floor, on all four sides.
  3. svh/dvh, never vh in a webview.
  4. Haptics are the best quality-per-line in mobile. Wrap them to no-op safely.
  5. When five reasonable fixes fail, question the model, not the implementation.
  6. "What's common across the failures?", the best debugging question in existence.
  7. The platform acts after your code, silently. Look below your layer.
  8. When the platform fights you, move rather than push.
  9. Comment hard-won fixes emphatically, or someone will simplify them back into the bug.
  10. The simulator lies. Keep a "not proven on hardware" list, and clear it before you claim you're ready.

Next: Chapter 19: Backend Architecture →

Useful? Share this chapter