You Want to Turn Your Web App Into a PWA?
So You Want to Turn Your Web App Into a PWA?
A while back, I went down the PWA rabbit hole for a side project, a little calorie tracker I’d built with React and Vite. I kept seeing the term “PWA” thrown around like everyone already knew what it meant, and every tutorial assumed I’d done this five times before. I hadn’t. So I wrote down everything I figured out.
If you’ve got a working web app and want it to feel like a real app - installable, with an icon, working offline, the whole deal - this is for you. No native code, no app store, no Expo. Just three things bolted onto what you already have.
First, what are we even building here?
A Progressive Web App is just… your website, but the browser is willing to treat it like an app. Means:
People can install it. It shows up on their home screen or desktop, same as anything else they’ve downloaded. It can keep working (or at least not completely fall apart) when the internet drops. It feels snappier because a bunch of files are sitting in a local cache instead of being re-downloaded every time. And on Android especially, you get a real app window - no address bar, your own icon, a splash screen while it loads.
To get all that, you need three things:
A manifest file that tells the browser, “Hey, this thing is installable, here’s what it should look like.” A service worker, which is a background script that handles caching and offline behavior. And a pile of meta tags in your HTML.
That’s genuinely it. Let’s go through each one.
Part 1: The manifest file
Think of the manifest as your app’s business card. It’s a JSON file, usually manifest.json — sitting in your public folder, and it tells the browser your app's name, what it looks like, what color scheme it uses, and how it should open.
Here’s a reasonable starting point:
{
"id": "...",
"name": "MyApp",
"short_name": "MyApp",
"description": "A short description of what your app does.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#f4f8f6",
"theme_color": "#21b86d",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Now let’s actually talk about what each of these does, because half of them are not self-explanatory.
name and short_name seem redundant but aren't. name is the full thing - shows up in install dialogs, splash screens, that kind of place. short_name is for when there's barely any room, like the little label squeezed under your home screen icon. Keep that one short.
start_url is where the app opens when someone taps the icon. Almost always just /.
scope defines what counts as “inside” your app. If a link takes the user outside this scope, it opens in a regular browser tab instead of staying in your nice app window. Makes sense, you don't want your installed app suddenly turning into a browser with tabs.
display is the big one for how your app feels.
display_override is a newer addition, and it's basically a wish list. You give the browser an ordered list of display modes to try, and it picks the first one it actually supports, falling back to whatever's in display if none of them work. So ["standalone", "minimal-ui"] means ‘try standalone, and if that's somehow not available, try minimal-ui.’ The nice thing is that older browsers that have no idea what display_override even is just ignore it completely, so there's no harm in including it.
background_color is the color of the splash screen that flashes up while your app is loading. Match it to your app's actual background, or you'll get this jarring flash of the wrong color before your app appears.
theme_color tints the browser's status bar and tab bar to match your brand. Small detail, but it's one of those things that makes an app feel ‘finished’ versus ‘thrown together.’
Part 2: The service worker
Okay, this is the part that sounds intimidating, but it's honestly just a JavaScript file that runs separately from your page. It can’t touch the DOM, it can’t see your React components, its whole job is to sit in the background and intercept network requests, deciding for each one whether to answer from a local cache, go fetch from the network, or do something else entirely.
This is the thing that makes offline mode and instant loading actually work. Here’s a solid baseline:
const CACHE_NAME = 'my-app-v1';
const APP_SHELL = [
'/',
'/index.html',
'/offline.html',
'/manifest.json',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return; const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request));
return;
}
if (url.origin !== self.location.origin) return;
event.respondWith(cacheFirst(request));
});
async function handleNavigation(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put('/index.html', response.clone());
}
return response;
} catch {
return (await caches.match('/index.html')) || (await caches.match('/offline.html'));
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
A lot is going on, so let’s slow down.
The three events you need to understand
A service worker’s life revolves around three events, and once these click, the rest of this stuff makes a lot more sense.
install fires once, the first time the service worker registers. Here's what's happening in ours: caches.open(CACHE_NAME) opens up (or creates) a named storage bucket, think of it like a folder just for this version of your app. Then cache.addAll(APP_SHELL) goes and downloads every file in your “app shell” list and stuffs it into that folder. One important catch: if even one of those files fails to download, the entire install fails. So don't put anything in APP_SHELL that might 404.
Then there’s self.skipWaiting(). By default, a brand new service worker is polite; it waits around until every tab using the old service worker has been closed before it takes over. skipWaiting() says “No, don't wait, take over now.”
activate runs once the new service worker is actually in control. This is cleanup time - it goes through all your cache buckets and deletes anything that isn't the current CACHE_NAME. This is the whole mechanism behind updates: you bump the version string from v1 to v2, and the old v1 cache gets wiped automatically during activation. Then self.clients.claim() makes this new service worker take charge of any pages that are already open, instead of waiting for the user to refresh.
fetch is where the actual work happens, on every single request your page makes. This is your chance to say “for this request, here's how I want to handle it.”
Cache-first vs. network-first
You’ll see these two terms everywhere in PWA-land, and they’re simpler than they sound.
Cache-first means: check the cache first. Got it? Great, return it instantly - no network round trip at all. Don’t have it? Fetch it from the network, save a copy for next time, then return it. This is perfect for stuff that basically never changes - your CSS, your JS bundles, fonts, images.
Network-first flips that around: always try the network first, because you want the freshest possible version. Only if the network fails - because the user’s offline, do you fall back to whatever’s cached, or to an offline page as a last resort. This is what you want for your actual HTML pages, where freshness matters more than speed.
There’s a third one worth knowing about, even though it’s not in the code above - stale-while-revalidate. You return the cached version immediately (so it’s fast), but at the same time, you quietly fetch a fresh copy in the background and save it for next time. Good middle ground for things like profile pictures, where slightly-stale data is fine but you still want to catch up eventually.
Two “why” questions people always ask
Why do we skip anything that isn’t a GET request? Because POST, PUT, and DELETE are usually your API calls - the things that actually change data, like “save this meal entry.” If the service worker intercepted one of those and handed back a cached response instead of letting it actually hit your server, the user’s action would just… vanish. Silently. That’s a nightmare to debug, so we just let those pass straight through.
And why skip requests to other origins? Because your API, your payment provider, and any third-party scripts live on different domains. The service worker’s job is to cache your files. Everything else should go straight to the network like normal.
Part 3: Actually turning the service worker on
Here’s something that tripped me up at first — just having serviceWorker.js sitting in your public folder does nothing. You have to register it from your app's own code.
// src/registerServiceWorker.js
export function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', async () => {
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
try {
const registration = await navigator.serviceWorker.register('/serviceWorker.js');
await registration.update();
} catch (error) {
console.error('Service worker registration failed:', error);
}
});
}
And then just call it once, somewhere near the top of your app:
// src/main.jsx
import { registerServiceWorker } from './registerServiceWorker';
registerServiceWorker();
A few of these lines look arbitrary, but aren’t. We wait for the load event, so the registration doesn't compete with your page's own resources while they're loading, let the page finish first, then quietly register the worker in the background.
registration.update() forces the browser to go check the server for a newer version of serviceWorker.js, even if its cached copy technically hasn't expired yet. Without this, someone who leaves a tab open for hours might just never get your update.
And the controllerchange reload, this one's subtle. When skipWaiting() and clients.claim() fire together, the active service worker can switch mid-session. If some of the page's assets were loaded under the old worker and some under the new one, you can end up in a weird half-and-half state. Reloading the page once everything's settled ensures the user gets a clean, consistent version.
Part 4: The HTML meta tag pile
Your index.html needs some extra tags to make all this play nicely across browsers — especially Safari, which has its own way of doing things.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="A short description of your app." />
<!-- Status bar color (Chrome/Android) -->
<meta name="theme-color" content="#21b86d" />
<!-- Android: tells Chrome this can be installed -->
<meta name="mobile-web-app-capable" content="yes" />
<!-- iOS: enables "Add to Home Screen" as standalone -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MyApp" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>NutriCal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Those apple-* tags exist because Apple had its own home-screen-app system before the Web App Manifest standard existed, and it's never fully gone away; it just kind of coexists alongside the modern stuff.
Now, here’s a correction to something a lot of older PWA guides still say. You’ll often read “Safari completely ignores manifest icons, you must use apple-touch-icon or you're stuck with a screenshot as your icon.” That used to be true, but Safari added support for reading icons straight from manifest.json. So on anything reasonably current, the manifest path actually works on iOS too.
That said, I’d still keep apple-touch-icon around. Here's why: if it's present in your HTML, it overrides whatever the manifest says. So it acts as a guaranteed, explicit fallback.
Part 5: A page for when things go dark
When someone’s offline, and the service worker can’t find what they’re asking for in the cache, it falls back to an offline page.
One thing that’s easy to forget: this file needs to be in your APP_SHELL list from earlier, so it gets cached during install. If it's not already in the cache by the time someone goes offline.
Part 6: Where everything actually lives
public/
manifest.json
serviceWorker.js ← must be at the root, not /src/
offline.html
icon-192.png
icon-512.png
The one thing here that genuinely confused me at first: why does serviceWorker.js have to sit at the root? It comes down to ‘scope’ - a service worker can only control pages that live at or below its own location. If you put it at /src/serviceWorker.js, it can only control stuff under /src/, which doesn't include your actual homepage at /. So it needs to live at the root, and public/ is the folder most build tools copy straight into your final output's root — which is why it ends up there.
Updating your PWA without breaking everyone’s cache
Here’s the update flow in order, because it trips people up:
You change your app’s code, your build tool spits out new file hashes. You bump CACHE_NAME from 'my-app-v1' to 'my-app-v2'just a string change. The next time someone visits, the browser notices serviceWorker.js itself has changed and starts installing the new version. install fires, and the new v2 cache gets populated with fresh files. Then activate fires, and the old v1 cache gets deleted. Finally, clients.claim() plus that controllerchange reload we set up earlier means the user gets bumped onto the new version automatically.
The one thing to really remember: if you forget to bump CACHE_NAME, none of this cleanup happens, and your users will keep seeing old cached files indefinitely, sometimes for a genuinely long time.
Things that go wrong (and what’s actually happening)
“My service worker won’t update no matter what I do.” Nine times out of ten, you forgot to bump CACHE_NAME.
“The install prompt just never shows up.” Chrome checks for HTTPS, a valid manifest with both icon sizes, and a registered service worker - all three, no exceptions. Open DevTools → Application → Manifest, and it’ll tell you exactly which one is failing.
“The offline page just doesn’t appear.” Make sure offline.html is listed in APP_SHELL so it gets cached during install, and double-check your navigation fallback is actually checking for it.
Conclusion
If there’s one thing I’d want you to take away from all this, it’s that converting to a PWA isn’t a rewrite of anything. It’s three files layered on top of an app you’ve already built. Add them, wire them up, and the browser handles the rest.