Process diagram: Four steps to a real React PWA. 4 stages, left to right: Scaffold with Vite then Add the manifest then Split the caches then Prompt on update.

How to build a real Progressive Web App with React

Ship an installable, offline-capable React PWA with Vite and vite-plugin-pwa, with a real Workbox caching config and an honest note on when a PWA is the wrong call.

What actually makes an app a PWA#

A Progressive Web App is not a framework, a library, or a rewrite. It is a normal web app that adds three things and nothing more:

  1. A web app manifest so the browser can offer to install it.
  2. A service worker registered over HTTPS so it can cache assets and respond when the network is gone.
  3. HTTPS itself, because service workers refuse to register on insecure origins.

That is the whole definition. If your React app already runs in a browser, you are most of the way there. Everything below is additive, which is the point: a PWA is progressive enhancement. If the service worker never registers, on an old browser or a flaky first load, the app still works. It just loses the offline and install parts.

Clearing up the React Native confusion#

Reaching for React Native "to get an app" when a PWA would do adds a build target, a review queue, and a second codebase you did not need. If the browser can serve it and the user can install it from there, you do not need a native binary to get an installable experience.

Use Vite, not Create React App#

Create React App is deprecated and no longer the recommended way to start a React project. Use Vite with vite-plugin-pwa, which wraps Google's Workbox so you never hand-write a service worker. Hand-written service workers are where most PWA bugs live: the lifecycle and cache invalidation are genuinely hard, and Workbox has already solved them.

bash
npm create vite@latest field-reports -- --template react-ts
cd field-reports
npm install -D vite-plugin-pwa

The plugin has two modes. generateSW lets Workbox write the entire service worker and auto-inject the precache manifest. Use it for most apps. injectManifest is for when you need custom runtime logic in the worker and want to own the file. Start with generateSW; you can graduate later.

vite.config.ts · ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'prompt',
      manifest: {
        name: 'Field Reports',
        short_name: 'Reports',
        start_url: '/',
        display: 'standalone',
        background_color: '#ffffff',
        theme_color: '#0b5cad',
        icons: [
          { src: '/icons/pwa-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/pwa-512.png', sizes: '512x512', type: 'image/png' }
        ]
      }
    })
  ]
})

That manifest block is what triggers the browser's install prompt. The icons are not optional; without at least a 192px and a 512px icon, most browsers will not offer to install.

Precaching versus runtime caching#

Two different caches do two different jobs, and calling them by the right name saves you hours later.

Precaching is your app shell: the HTML, JS, and CSS Workbox knows about at build time. It generates a precache manifest of every hashed file and stores it on install. This is what makes the app open instantly and load offline.

Runtime caching is everything you cannot know at build time: API responses, images, third-party assets. You configure it with strategies. The two you will reach for most:

  • StaleWhileRevalidate: serve the cached copy immediately, fetch a fresh one in the background for next time. Right for API data that can be a few seconds stale.
  • CacheFirst: serve from cache and only hit the network on a miss. Right for images and fonts that do not change under the same URL.
vite.config.ts · ts
// inside VitePWA({ ... })
workbox: {
  runtimeCaching: [
    {
      urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'api-cache',
        expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
      }
    },
    {
      urlPattern: ({ request }) => request.destination === 'image',
      handler: 'CacheFirst',
      options: {
        cacheName: 'image-cache',
        expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }
      }
    }
  ]
}

Under the hood the service worker runs on its own worker thread with no DOM access. It intercepts fetch events and answers them from the Cache Storage API, which is why it can respond with no network at all. Every request routes through one decision:

How a request routes through the service workerThe app shell is served from precache; API data and media use their own runtime-cache strategies; only a miss falls through to the network.

The lifecycle is install then activate then fetch, and that ordering is exactly what trips people up on deploys.

Service worker lifecycle
  1. Install

    The new service worker downloads and precaches the app shell (hashed HTML, JS, CSS). It waits in the background and does not control any page yet.

  2. Activate

    Once no pages are controlled by the old worker, the new one activates and cleans up outdated caches. Now it is in charge.

  3. Fetch

    Every network request passes through the worker. It answers from the Cache Storage API when it can, so the app responds instantly and works offline.

Step through the lifecycle, then toggle a new deploy to see why an update can appear stuck until the user refreshes.

The hard part: updates without stale assets#

Here is the bug every PWA team hits. You deploy new code. A returning user still has the old service worker running, so it keeps serving the old cached bundle. Nothing broke; the update just does not show up. A service worker does not take over from its predecessor until every tab controlled by the old one is closed.

registerType: 'prompt' handles this honestly. Instead of silently swapping code mid-session, you tell the user a new version is ready and let them refresh.

src/pwa.ts · ts
// src/pwa.ts
import { registerSW } from 'virtual:pwa-register'

const updateSW = registerSW({
  onNeedRefresh() {
    // Render a small toast: "New version available."
    // On click: updateSW(true) reloads with the fresh service worker.
  },
  onOfflineReady() {
    // Optional toast: "Ready to work offline."
  }
})

Worked example: what the payload win looks like#

To see why teams reach for a PWA instead of a native install, look at Twitter Lite, a React PWA whose numbers Google published on web.dev. The story is the ratio: an installable experience at a fraction of a native binary's weight, which matters most on the slow networks and cheaper devices where a large download is a real barrier.

Twitter Lite PWA vs the native Android app~40x smaller
Lighter

~600 KB

Twitter Lite (React PWA)

23.5 MB

Native Android app

Same core experience, delivered at roughly one fortieth of the download. Image optimization also cut timeline data use by up to 70%, and after launch Twitter reported 65% more pages per session and 75% more Tweets sent.

Twitter Lite PWA vs the native Android app (initial transfer size)
Optioninitial transfer size
Twitter Lite (React PWA)~600 KB
Native Android app23.5 MB

Source: web.dev case study (Twitter's figures, not Atyantik client data)

Those are Twitter's results as reported by web.dev, not Atyantik client figures. The point is not the exact percentages; it is that an installable web app can be dramatically lighter than the native binary it replaces.

A payload win is only real once it is measured on a mobile profile rather than a developer laptop. Our walkthrough of how the Lighthouse performance score is actually computed, metric by metric explains which numbers move when a service worker starts serving assets, and which ones do not move at all.

When not to build a PWA#

A PWA is the right tool often, not always. Skip it or pick something else when:

  • You need deep native capability that iOS still gates behind app stores, such as reliable background push on older iOS, Bluetooth, NFC, or tight hardware integration. Test the specific API on your target devices before you commit.
  • App store presence itself is the requirement, because your users search the store or your distribution model depends on store billing.
  • Your app is a short-session, one-and-done flow with no offline or re-engagement value. The service worker is maintenance you would carry for no return.
  • You cannot serve over HTTPS across every route. Without it, none of this registers.

If your product is content-heavy, re-engagement matters, and you want one codebase that works on any device with a URL, a PWA earns its keep. For the rest, be honest that a plain web app or a native build is the better fit.

Where to go next#

Getting the service worker right is one piece of shipping fast. If speed is the broader goal, see how we approach Core Web Vitals and performance. If you are weighing how to render the app in the first place, the companion piece covers rendering a React app on Cloudflare across SSG, SSR, and edge. If you are validating an idea, this same stack is how we ship a production MVP, and it extends naturally into mobile app development.

If you want a second set of hands on an installable React build, you can hire React developers from our team, or talk to us about the fit. No pressure, and no lock-in: everything above is standard, documented web platform work you own outright.

Portrait of Tirth Bodawala

Tirth Bodawala

Chief Technology Officer, Atyantik Technologies

Tirth leads engineering at Atyantik Technologies, a software product studio building web platforms, mobile apps, and AI-integrated systems since 2015. He writes about shipping software that holds up in production, from rendering performance to the platform decisions behind a launch.

More from Tirth BodawalaAbout AtyantikHire React developers

Keep reading