Back to all writing

I Put My Web App on Android and Immediately Hated It

IPMAT Series•Part 27•9 min read•By Mikhil

I Put My Web App on Android and Immediately Hated It

The APK worked.

That should have felt like a major victory.

Instead, I opened it and thought:

This feels like a website trapped inside my phone.

Technically, that is almost exactly what it was.

The Verbal app had reached the stage where I wanted to test Android properly.

So I packaged the web experience.

The build existed.

It installed.

It opened.

And suddenly a new category of problems appeared.

Not:

Does the feature work?

But:

Does this feel like it belongs here?

The answer was:

Not yet.

Running on Android is not the same as being an Android app

This distinction seems obvious now.

It did not feel obvious when the first build succeeded.

A web application can be responsive.

It can fit on a phone.

It can even be installed inside a native shell.

That does not automatically make the experience native.

Android has expectations.

The system back button matters.

The status bar matters.

Safe areas matter.

The keyboard matters.

Haptics matter.

App resume matters.

Backgrounding matters.

Deep links matter.

Storage behaves differently.

The lifecycle is different.

The phone is not just a small browser.

The status bar immediately exposed the illusion

The first builds could push content awkwardly under system chrome.

On desktop, there is no equivalent problem.

The app owns its rectangle.

On a phone, the operating system shares the screen.

A top bar that looked perfect in the browser suddenly felt cramped or misplaced.

That sounds cosmetic.

It changes whether the application feels intentional.

Native spacing is not just smaller responsive spacing.

The interface has to understand the device.

Then the back button fought the app

Web navigation has its own history model.

Android users expect the physical or system back action to behave naturally.

If the app intercepts it badly, navigation immediately feels broken.

A modal should close.

A nested screen should return.

The app should not suddenly exit.

A solving session should not disappear accidentally.

That behaviour has to be designed.

Not merely inherited from the browser.

The back button became one of the first reminders that mobile interaction has its own grammar.

The keyboard made everything worse

Forms that feel fine on desktop can become painful when the mobile keyboard appears.

Short inputs.

Search.

Authentication.

Sentence practice.

Any screen with typing can suddenly lose half its height.

Buttons get covered.

Scroll positions move.

The wrong field remains focused.

The layout jumps.

Again:

The website can be responsive and still fail at being a comfortable mobile app.

The keyboard is part of the screen.

The UI has to react to it.

I wanted haptics immediately

This was one of the fun parts.

A correct answer can feel different with a small tactile response.

Navigation can feel more physical.

Completion can have weight.

But even haptics became a proper engineering problem.

Preferences need to be respected.

Different components should not trigger overlapping vibration.

Reduced feedback settings should actually work.

The app should not become annoying because every tap buzzes.

Native capability is useful only when it remains controlled.

The first native pass still inherited too much web thinking

I tried improving the shell.

Better spacing.

Mobile navigation.

Safe areas.

Back handling.

Haptics.

It helped.

The deeper problem remained.

The information architecture had been designed from a web-first assumption.

Some surfaces were too dense.

Some interactions were too small.

Some navigation felt like desktop tabs squeezed into a phone.

At that point, I stopped asking:

How do I make the WebView look nicer?

and started asking:

What would this experience look like if I designed it for the phone first?

That question eventually pushed the native work further.

Then the repository had two Android stories

This became messy.

The project contained the older Capacitor-based Android path.

Then a newer Expo React Native application appeared under a separate native project.

That meant the repository effectively had two different answers to:

How do I build Android?

Old scripts still existed.

New scripts existed somewhere else.

Commands became confusing.

Generated Android files behaved differently.

A future release process could easily run the wrong path.

That is not sustainable.

The old implementation needs to be clearly archived or removed from the active path.

A product should have one official Android build story.

Expo changed the question again

Moving toward React Native gave me more control over the native experience.

Now the goal was not:

Render the existing website inside a shell.

It became:

Reuse the product logic while building a mobile interface that feels native.

That is a much bigger job.

It also feels like the correct one.

The product should share learning logic, evidence semantics and backend contracts.

It does not need to share every DOM element.

Reuse should happen at the right layer.

A debug APK is not a release

This was another good reality check.

A small APK can install and work.

That does not mean the Android product is ready.

Production signing matters.

Permissions matter.

Build reproducibility matters.

Release commands matter.

Secrets matter.

Testing upgrades matters.

Fresh install matters.

Store declarations matter.

A debug keystore is fine for private testing.

It is not a release strategy.

The distance between:

It runs on my phone

and:

I can responsibly ship this

is large.

Permissions forced another audit

One generated Android configuration included permissions the app did not actually need.

That is a bad smell.

If the product only plays cue sounds, it should not casually request microphone access.

Mobile permissions are visible.

They affect trust.

The rule should be:

Ask for what the feature genuinely requires.

Nothing else.

A package can become invasive accidentally through dependency defaults.

That still counts.

Even a tiny sound effect can fail differently on native

The cue sound implementation had been fine enough in one environment.

On native, the embedded approach was less reliable.

The better solution is boring:

Ship a real local audio asset.

Load it predictably.

Test it on the target platform.

This entire Android process has been a collection of lessons that look obvious after the bug appears.

Persistence became much more serious

A browser tab ending is one failure mode.

A mobile app can be backgrounded.

Killed.

Suspended.

Reopened hours later.

Lose memory.

Have storage pressure.

Receive a deep link.

Resume with a different network state.

That exposes weaknesses in session recovery immediately.

If I am halfway through a mock and Android kills the process, what happens?

If I am halfway through practice?

If the app returns from background with a stale auth token?

If pending evidence has not synced?

Native forces those questions earlier.

Fixed mocks exposed the recovery gap

One specific problem made this clear.

A fixed mock draft depended on navigation context supplied when opening the screen.

After closing and reopening the app, that context was gone.

The stored draft alone was not enough to reconstruct the attempt safely.

That means:

We saved some state

was not the same as:

The exam is recoverable.

Recovery needs all the information required to rebuild the exact session.

Content version.

Order.

Timer.

Responses.

Navigation state.

Ownership.

That is a much stronger requirement.

The same was true for normal practice

Completed answers could survive.

The exact unfinished practice state did not always.

Position.

Current question.

Timer.

Pending response.

Session context.

Those details matter if the product promises resume.

Mobile makes partial persistence obvious because app termination is normal.

Not exceptional.

Tests gave me false confidence too

This was uncomfortable.

Some verification scripts were still looking for old migration filenames.

Some important tests were not included in the main validation command.

A few critical checks failed immediately when run directly.

That means previous:

Everything passes

results did not actually prove everything I thought they proved.

This is exactly why I keep writing checkpoint articles.

A green test suite is only useful if it is testing the right things.

Native parity is harder than feature parity

Suppose Verbal web has a feature.

The Android app also has a screen with the same name.

Parity?

Not necessarily.

Does it use the same learning rules?

Does progress mean the same thing?

Does XP agree?

Does the review queue behave the same?

Do settings sync?

Do exams recover the same way?

Does offline work the same way?

Does the diagnostic produce the same plan?

Visual parity is the shallowest form of parity.

Behavioural parity is the real goal.

I stopped wanting a wrapper

This was the turning point.

At first, Android was packaging.

Now it is product work.

The native app needs its own navigation.

Its own lifecycle handling.

Its own accessibility.

Its own tactile behaviour.

Its own keyboard comfort.

Its own safe-area logic.

Its own release pipeline.

While still sharing the same underlying learning truth.

That is harder.

It is also much more interesting.

The Android work is not finished

I have real native progress.

I also have real unresolved problems.

TypeScript and release verification still need tightening.

The old Capacitor path still creates confusion.

Production signing needs to be final.

Permissions need cleanup.

Haptic preferences need consistency.

Practice and mock recovery need stronger parity.

Offline and reconnect behaviour need more testing.

Account isolation needs to survive backgrounding and switching.

That is the current truth.

But the bad first build was useful

If the first Android package had looked perfect in screenshots, I might have convinced myself the work was done.

Actually using it made the problems obvious.

Cramped navigation.

Wrong platform feel.

Back behaviour.

Status bars.

Keyboard issues.

Missing tactile feedback.

Recovery gaps.

That frustration was useful.

It forced the project from:

Can I run the web app on Android?

to:

Can I build a mobile product I actually want to use?

Those are completely different goals.

The first one is mostly solved by packaging.

The second one is still being earned.