Shipping Apps Without the App Store — PWA First, Store Only When You Need It

ONTLE keeps PWA as the default for turning the web into an installable app with no review process, and only wraps it in a TWA for Google Play when store distribution actually becomes necessary. Here's the thinking behind that call.
← 목록으로
박
박지훈
2026년 10월 02일 Updated 2026.10.16 38 min read views 2

Anyone who has waited days in a review queue just to ship one app knows the feeling. Change a single button color and you're back in line again, and you're maintaining two codebases for iOS and Android. But planting an icon on the home screen and pushing notifications even after the app is closed doesn't actually require going through a store.

The principle ONTLE set when shipping app.ontle.kr is simple: deploy to the web first, and add a layer on top only when store distribution genuinely becomes necessary. The standard for turning a website into an installable app is the PWA (Progressive Web App), and the method for wrapping that app in an Android store shell is the TWA (Trusted Web Activity). PWA is the default; TWA is an option.

Let's walk through why this order makes sense — and what it actually cost us when we moved to the store, including the spot where ONTLE tripped and fell.

Think of it as a neighborhood shop

Say you've just opened a shop. Getting onto a delivery app means passing review, paying commissions, and competing for visibility. But what if you told customers who already stopped by, "Save our number to your home screen and next time you can come straight to us"? The customer skips the delivery app and comes directly. That's a PWA install. Then, once you really want exposure in a high-traffic commercial district, that's when you get onto a delivery app. That's TWA and store registration.

You don't need to design your shop around joining a platform from day one. Keep just this one thing in mind and the rest follows. Every technology coming up is a concrete part of either "building regulars without a delivery app" or "joining a platform only when you truly need to."

The two pillars of a PWA — the manifest and the service worker

A PWA starts by adding two files to a web service you already have. One is the manifest, the other is the service worker.

The manifest — the app's face, carved into the phone

The manifest (manifest.json) is a small config file that tells the browser, "this site can be installed like an app." It holds the name, icons, launch screen, colors, and how it runs. The moment someone installs, this information is carved straight into their phone.

{
  "name": "온틀 앱",
  "short_name": "ONTLE",
  "start_url": "/app?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2550db",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

The most important value here is display: standalone. With it, the installed app opens in its own window without the browser's address bar. That first impression — indistinguishable from a native app — is decided right here. Hooking it up takes a single line in your HTML: <link rel="manifest" href="/manifest.json">.

The service worker — the worker that stays awake even when the app is off

The service worker is a script that runs in the background, separate from the screen. It intercepts network requests and stores them (caching), shows a fallback screen when the connection drops, and receives notification signals to display them on screen. Even when the page is closed, this worker stays inside the browser and does its job.

// sw.js — conceptual example
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("ontle-shell-v1").then((cache) =>
      cache.addAll(["/", "/offline.html", "/icons/icon-192.png"])
    )
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request).catch(async () => {
      const cached = await caches.match(event.request);
      return cached || caches.match("/offline.html");
    })
  );
});

Registration is one line: navigator.serviceWorker.register("/sw.js"). From that moment on, offline handling, caching, and notification delivery all revolve around this single file.

The fetch handler above is the simplest possible form — "network first, and whatever's stored if that fails." But the real value of a service worker lies in setting a different strategy for each resource. For files that rarely change, like a logo or a font, serving the stored copy first means you don't have to hit the server every time, so the screen appears fast. Conversely, for data where freshness always matters, like a live feed, you go network first to keep it current. Even within the same service, the logo and the news feed should have different strategies, and all of that judgment is designed inside this one file. A service worker isn't a "connection-failure handler" — it's a performance design tool that shapes how fast things feel.

How a user actually installs the app

If the manifest and service worker are the technical foundation, installing is the experience the user actually goes through. And that experience differs quite a bit from platform to platform.

Chrome-family browsers fire a signal called beforeinstallprompt on sites that meet the install criteria. It's the browser telling you first, "this site — looks installable." The conditions include HTTPS, a manifest with the required fields, and the site not already being installed, among others — but the exact bar has shifted across browser versions. It's safest to check the precise, current conditions against the official docs.

The standard approach is to catch this signal and show your own custom "Install app" button. If you leave it entirely to the browser's default banner, you have no control over when it appears.

let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

installButton.addEventListener("click", async () => {
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  // outcome: "accepted" | "dismissed"
});

Timing is everything. Shove an install popup in a user's face the instant they first arrive and they'll just leave. Show it as a natural button at the moment the user has felt the service's value — on a second visit, or after they've used a core feature once.

The problem is iOS. Safari doesn't send this signal at all. To install on iOS, the user has to tap the share button and pick "Add to Home Screen" themselves. So an iOS install prompt can't be "one button" — it has to be "a screen that walks you through the steps with pictures." This asymmetry comes back to bite us in notifications, which we'll get to next.

Notifications that arrive even after you close the app

The key to web notifications is this: the server doesn't jab them directly into the user's phone. There's a post office in the middle — a push service run by the browser vendor. The server hands the notification off here, and the post office delivers it to the user's device.

But if the post office accepted just any letter, spam would flood in. So when the server hands over a letter, it has to prove its identity: "it's me, a registered sender." The name of this identity-proving arrangement is VAPID. You don't need to know the name. Remember just one thing — it's a signature that proves who sent the letter, not a lock that seals the letter's contents.

This distinction matters in practice. Proving the sender and encrypting the contents are two completely separate jobs. The server stamps an identity signature on the envelope (VAPID), encrypts the letter's contents separately, and hands both to the post office together. The post office checks only the envelope's signature before delivering; it can't open the sealed contents. Then the service worker, which was asleep on the device, wakes up and shows the notification.

This is exactly why notifications arrive even after you close the tab. The service worker is separated from the page and lives in a space the browser manages directly, so even when the tab is gone, the browser can wake just the service worker when a letter arrives. That said, "closing the tab" and "fully quitting the browser" are different. Android uses the system notification channel, so notifications generally arrive even when the browser isn't running. The desktop, on the other hand, depends on the combination. Firefox cuts the connection when fully quit, but Edge on Windows ties into the OS notification system and receives notifications even when closed. It's safest not to assume "desktop users are always reachable."

// client — creating a subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY, // base64 → Uint8Array conversion required
});
// send the subscription to the server and store it

The iPhone exception

Platform Notifications Conditions
Android Chrome Supported Generally reaches even when the browser isn't running
Desktop Chrome/Edge/Firefox Supported Delivery conditions vary by browser/OS combination
iOS Safari (straight from a tab) Not supported No notifications without installing
iOS home-screen installed PWA Supported iOS 16.4+ and home-screen install and permission granted

iOS only began supporting web notifications in 16.4, and even then they work only from an app installed to the home screen, not from a browser tab. Which means that to send a notification to an iPhone user, the "install" step must always come first. "I wired up web push but it won't arrive on iPhone" is the most common mistake in practice, and this is where it comes from. ONTLE, too, initially ran notifications mainly for Android users who had finished installing, and split its strategy toward guiding iOS users to install first.

The road to the store — TWA, and the address bar that wouldn't go away

That's "building regulars without a delivery app." Now it's time to join the platform.

TWA launches the Chrome engine full-screen with the address bar completely hidden, making it look like a native app on Android. Under the hood it's still the web, but to the user's eye it's indistinguishable from an ordinary app. Bubblewrap, a command-line tool Google released, reads the manifest and generates the Android project for you automatically.

npx @bubblewrap/cli init --manifest https://app.ontle.kr/manifest.json
npx @bubblewrap/cli build

That part is easy. The real trap was what came next.

When ONTLE built app.ontle.kr with Bubblewrap, pushed it to Google Play, and opened the app on a device, the address bar was still sitting at the top — even though it was clearly wrapped in a TWA. "Like a native app" is the whole reason TWA exists, and that very core wasn't working.

The cause was verification failure. To hide the address bar, you first have to prove that "this Android app and this web domain share one owner." It's a procedure where the website and the app vouch for each other — "we're one family" — and that certificate of vouching is an assetlinks.json file placed on the domain. The certificate carries a stamp, and that stamp is the SHA-256 fingerprint of the app's signing certificate.

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "kr.ontle.app",
    "sha256_cert_fingerprints": ["SHA-256 fingerprint of the app signing certificate"]
  }
}]

The stamp ONTLE had placed was wrong. We'd put in the fingerprint of the upload key used for the local build — but that's not the file that actually gets installed on users' devices.

Here you have to understand how Play App Signing works. These days, Google Play re-signs the app you upload before distributing it to users. So there are two keys. One is the upload key the developer uses to submit the app to Google; the other is the distribution app signing key Google stamps on as the final step right before releasing to users. What you need to register in the certificate is the one that actually reaches the user's hands — the fingerprint of the distribution app signing key. Because ONTLE put in the upload key fingerprint, from Chrome's point of view the "app and domain are one family" vouching didn't hold, so it put the address bar back.

The fix was simple. On the App integrity screen in Play Console, we copied the SHA-256 fingerprint of the app signing key certificate and swapped it into assetlinks.json. We also put the upload key fingerprint into the array alongside it, so local test builds would verify too. Once we deployed the file to the domain and reopened the app, the address bar vanished, and both the release build and the test build were indistinguishable from a native app.

The lesson is clear. When the address bar won't disappear in a TWA, nine times out of ten you should suspect a fingerprint mismatch before the code. And that hinges on whether you know the fact that "the key I signed with ≠ the key Google actually stamps on for distribution." This one thing was what held ONTLE up longest in getting into the store.

For reference, this road is Android-only. iOS has no official path equivalent to TWA. What Apple blocks is "an app that's just a wrapper around a website," not all web-based apps — so if you build in enough polish as an app, App Store distribution is possible even on a web view. That road, though, demands the investment of separate native packaging and clearing review. If you're not making that investment, a home-screen installed PWA is the realistic answer on iOS.

So how did ONTLE decide?

What the fingerprint incident tells us is clear. The moment you head for the store, costs appear that the web never had: key management, a signing scheme, and review. So when to pay that cost is the whole strategy.

ONTLE's decision rested on three axes — cost, speed, and trust.

When a PWA alone is enough. A service people enter by clicking a link; an early stage where fast, repeated deployment is lifeblood; a team that has to cover iOS, Android, and desktop from a single codebase; cases where review delays would be fatal to the business. Here the store is a burden, not a benefit.

When you need store registration too. A service with a clear user base that "only trusts what they can search for in the app store"; cases where Play Store search and recommendations are an important inflow channel; environments where the install file itself is required, like enterprise mobile device management (MDM) deployment.

Item PWA only PWA + TWA (Google Play)
Dev & maintenance cost One web codebase Web code + Android packaging & signing management
Deploy speed Instant (no review) Play review process required
Search visibility Web search & URL sharing Above + Play Store search
iOS Possible via home-screen install N/A (Android-only)
Updates Reflected via server deploy (depends on service worker update strategy) Content same as left; store update only when the shell changes
Payments Free choice of web payment methods Review against Play policy when selling in-app digital goods

The reason ONTLE didn't prepare a TWA from the start is in this table. Early on, the whole flow was customers coming in via a link and installing to the home screen. Install, offline, and notifications all ran on the manifest and service worker alone, and fixes took effect the moment they were pushed to the server. There was no reason to stand in a review queue.

We pulled out the store card at the point where Android users genuinely needed Play Store as a distribution channel. Only then did we feed the already-running manifest straight into Bubblewrap and wrap it in a TWA — without taking on a new native codebase. The price we paid for it was exactly that fingerprint incident, and having been through it once, we never tripped at the same spot again.

Seen through one work scene

When ONTLE's AI PM takes on a customer project, this is the first call it makes. Suppose a request comes in to build an inspection tool for the company's on-site field staff. The users are all on Android, the inflow is an admin blasting out a link, and the requirement was "input must not be lost even if the internet drops in the field."

No store is needed here. You open home-screen install with the manifest, and design the service worker's offline strategy so that input from the disconnected stretch is stored and uploaded once the connection recovers. With no review and no signing key management, a same-day fix takes effect the same day. If later this tool gets distributed out to partner companies and a condition attaches — "the install file has to be pushed via enterprise MDM" — that's when you wrap the same manifest in a TWA and put it on Play. Keep the order, and you never carry a heavy load from the start.

FAQ

What's actually different between an installed PWA and an app store app?

On the surface they launch similarly. A home-screen icon opens an independent window, and caching and notifications work. The real difference is in features deep in the device. Areas that touch the inner parts of the OS — Bluetooth, precise background tasks, certain sensors — are more broadly accessible to native apps. The range of features a PWA can use varies by browser/OS combination.

I deployed a changed service worker, so why doesn't it take effect right away?

By default, a new service worker isn't swapped in immediately — it enters a "waiting" state and only activates after all existing tabs are closed. Not knowing this leads to the confusion of "I definitely pushed it but nothing changed." If you need an urgent update, you have to add a separate strategy to skip the waiting at the install step.

If I put it on Google Play via TWA, is there no review?

There is. A TWA is a full-fledged Android app registered on Play, so it goes through policy review. The point is that you don't have to write new native code, so preparation is lighter — not that review itself is skipped.

It's an internal tool on Android only — is it still right to go PWA first?

Yes. Even if all your users are on Android, if you don't need store distribution, a PWA is faster and cheaper. Paying the cost of review and signing key management can wait until a condition arises where "the install file is actually needed" — like Play Store search or MDM deployment. That's not too late.

References