Skip to content
← Journal

Engineering

A PWA next to a native app

ReadyToBinge runs as a PWA in the browser and as a separate app in the stores. What a PWA takes technically, what service workers do and where iOS draws the line.

9 November 2026 · 6 min read

ReadyToBinge is a trailer feed in React and TypeScript, with Cloudflare behind it and data from TMDB. In the browser it is a progressive web app, or PWA, which you can put on your phone as a web app. Next to that, a separate native app is in the App Store and on Google Play.

That app is there because people expect an app in the store, and because I still think an app is the better choice when people mainly use something on their phone. If you need actual native things, such as camera, location or notifications, a real mobile app is the best choice. If a responsive web app can do the job, that is enough. The choice itself is covered in App, web app or website. This post is about what the PWA takes technically, and where it stops.

What a PWA is

A PWA is a website on HTTPS with a web app manifest and usually a service worker. The manifest tells the browser what the app is called, which icons it has and how it opens. With standalone the address bar goes away and it looks like an ordinary app. This is a simplified illustration, not ReadyToBinge’s manifest:

{
  "name": "Example app",
  "short_name": "Example",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#1b1510",
  "theme_color": "#1b1510",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

What a service worker does

A service worker is a script that runs apart from the page and sits between the page and the network. It can intercept requests from pages in its scope and answer them from the cache. Web push goes through it too.

The strategy depends on the kind of request. Files with a hash in the name can safely be served cache-first, because the name changes when the content changes. HTML is best fetched from the network first, with the cache as a fallback, because otherwise someone stays stuck on an old version of the app. API data depends on how fresh it has to be. Video is best left alone. Those files are large and use range requests, and a feed full of trailers would quickly use up your storage quota.

// Simplified illustration, not ReadyToBinge's code.
self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (request.destination === "video") return; // the browser handles this

  if (request.mode === "navigate") {
    event.respondWith(fetch(request).catch(() => caches.match("/offline")));
    return;
  }

  if (url.pathname.startsWith("/assets/")) {
    event.respondWith(caches.match(request).then((hit) => hit ?? fetch(request)));
  }
});

The hardest part is updating. A new service worker waits until all tabs or windows with the old version are closed, and an installed app often stays open for a long time. You can use skipWaiting, but then a page still running can suddenly get a new worker and ask for files that no longer exist. Keep old hashed files on your server for a while, or show the user a message that a new version is ready.

Installing

How a browser decides whether you can install changes over time, so check the current rules. Chrome expects a manifest with, among other things, a name and icons. The requirement for a service worker with a fetch handler was dropped for installing through the menu from version 108 on mobile and 112 on desktop.

In Chromium browsers you can catch the beforeinstallprompt event and show your own button at a moment you choose. That event is not a standard, and Safari does not have it.

On iOS, someone installs through the share menu and “Add to Home Screen”. You cannot start that from your code, so you will have to explain it in your own interface. With the media query display-mode: standalone you can check whether the app is already running installed, and then hide that explanation.

That is one step more than in an app store, where people are used to pressing a button. On the other hand, a link works straight away: send someone a link to a page and it opens in the browser, with no install.

What iOS limits

Apple changes this regularly, so test on real devices and check the WebKit documentation. At the time of writing, this is what it comes down to:

API keys

The user can download and read everything in a PWA, so an API key in your front-end is a public key. That goes for TMDB too. A small server in between solves that and can cache answers at the same time, and with Cloudflare a Worker is an obvious place for it.

What you gain and what you give up

You have one codebase for iPhone, Android and desktop. You put an update online with no review, and your users do not have to install anything for it. Every page has a link you can share.

With only a PWA, you are not in the app store, where people go to look for apps. On iOS you have the limits above. For access to the device you depend on what the browser offers, and a native app has more access. How to weigh this for a business is in Do you need the app store?

That is why ReadyToBinge also has a native app in the stores, next to the PWA.

Read next

What's your idea?

Tell me what you're trying to build. I try to reply within one working day.