Progressive Web Apps vs Native Mobile Experience

·19 min read·Web Developmentintermediate

Why this choice matters more than ever as web capabilities close the gap on mobile

Split screen illustration showing a mobile web app interface on the left and a native app interface on the right, demonstrating the difference in UI and capabilities.

If you’ve spent any time building for mobile in the last few years, you’ve likely felt the tug-of-war between Progressive Web Apps (PWAs) and native development. A client asks for an app. Marketing wants it in the app stores. Engineering wants to move fast and avoid maintaining two codebases. Meanwhile, the web platform keeps adding features that were once exclusive to native: offline storage, installability, push notifications, and even access to Bluetooth and NFC in some contexts.

I’ve built PWAs for internal tools that needed to reach every device without an app store, and I’ve shipped native apps when we needed tight hardware integration. Neither approach is a silver bullet. The right choice depends on your users, your features, your team’s skills, and your maintenance appetite. In this post, I’ll break down the tradeoffs with real-world examples and code, highlight where PWAs shine and where native still wins, and share the practical patterns I’ve used to make this decision without regrets.

Context: Where PWAs and native fit today

PWAs are web apps that use modern browser APIs to behave like installed apps. They can be installed on the home screen, work offline with a service worker, and send push notifications on supported platforms. The capabilities vary by platform, especially on iOS, where Apple has been more conservative. Android has been more aggressive, but even there, certain features depend on browser engines and OS versions.

Native apps are built for a specific platform using its native tooling and language, like Swift or Objective-C for iOS, Kotlin or Java for Android, or cross-platform frameworks like Flutter or React Native. They have full access to the OS, consistent notifications, and deeper integration with hardware. They also come with app store distribution, review processes, and ongoing platform updates.

In practice:

  • PWAs are chosen when teams want to reach a wide audience quickly, reduce development overhead, and avoid store policies. They’re common for content platforms, internal tools, marketplaces, and e-commerce where discoverability is via search rather than store listings.
  • Native apps are chosen when performance, offline reliability, or deep integrations (biometrics, sensors, background tasks) are critical. They’re common for banking, fitness, media editing, and games.

A pragmatic way to frame the decision is through user and business constraints:

  • Distribution: Do users find you through search or store discovery?
  • Capabilities: Do you need platform features the web doesn’t offer?
  • Performance: Do you need predictable 60fps animations and minimal startup time?
  • Team: Do you have capacity to maintain two native codebases or a cross-platform framework?

Technical core: PWA capabilities, patterns, and limits

Service workers and offline strategies

A service worker is a script that runs in the background, separate from your web page, enabling caching and offline behavior. It’s the backbone of PWA reliability. The most practical pattern I use is “stale-while-revalidate” for dynamic content and a cache-first strategy for static assets.

Example: A basic service worker that caches the app shell and fetches network data with fallback.

// public/sw.js
const APP_CACHE = 'app-shell-v1';
const API_CACHE = 'api-data-v1';

self.addEventListener('install', (event) => {
  self.skipWaiting();
  event.waitUntil(
    caches.open(APP_CACHE).then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js',
        '/icons/icon-192.png',
      ]);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.filter((k) => k !== APP_CACHE && k !== API_CACHE).map((k) => caches.delete(k))
      );
    })
  );
});

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      caches.open(API_CACHE).then((cache) => {
        return cache.match(event.request).then((cached) => {
          const fetchPromise = fetch(event.request)
            .then((networkResponse) => {
              cache.put(event.request, networkResponse.clone());
              return networkResponse;
            })
            .catch(() => cached);
          return cached || fetchPromise;
        });
      })
    );
    return;
  }

  // App shell: cache-first
  if (event.request.mode === 'navigate' || event.request.destination === 'document') {
    event.respondWith(
      caches.match('/index.html').then((cached) => cached || fetch(event.request))
    );
    return;
  }

  // Static assets: cache-first
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

In a real project, you’ll register the service worker and handle updates carefully to avoid stale UI. Here’s the registration pattern I typically use:

// public/app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service worker registered', registration.scope);

      // Listen for updates
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // A new update is available; prompt the user to refresh
            const ok = window.confirm('A new version is available. Refresh now?');
            if (ok) window.location.reload();
          }
        });
      });
    } catch (err) {
      console.error('Service worker registration failed', err);
    }
  });
}

Notes:

  • On iOS Safari, service workers are supported as of iOS 11.3, but background sync and some notifications behave differently.
  • Some features (like background sync) are more reliable on Android/Chrome. Always check platform tables: MDN’s PWA feature support and web.dev’s PWA guidance are good references.

Web app manifest and installability

The manifest tells the browser how your app should behave when installed: name, theme colors, icons, display mode, and start URL.

Example: public/manifest.json

{
  "name": "Tasks PWA",
  "short_name": "Tasks",
  "description": "Offline-ready task manager with notifications",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    }
  ]
}

Link it in your HTML:

<head>
  <link rel="manifest" href="/manifest.json" />
  <meta name="theme-color" content="#4f46e5" />
</head>

Installability depends on criteria like a service worker, manifest, and engagement. If the “Install” prompt doesn’t appear, check the browser’s DevTools Application panel. On Android Chrome, you can create a trusted web activity (TWA) to package your PWA for the Play Store, which is a middle ground between web and native distribution. For TWA, see the official documentation: Trusted Web Activity on Android.

Push notifications on the web

Push notifications are possible on Android/Chrome and Firefox. iOS Safari only supports push notifications starting with iOS 16.4 when the site is added to the home screen, and the experience is more limited compared to native. This is a common gotcha.

Example: Request permission and subscribe to a push service (simplified; requires a backend and VAPID keys for production).

// public/notifications.js
async function subscribeToPush() {
  if (!('PushManager' in window)) {
    alert('Push not supported on this browser');
    return;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    alert('Permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY'),
  });

  // Send subscription to your server
  await fetch('/api/push-subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}

// Helper to convert VAPID key
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

document.getElementById('notify')?.addEventListener('click', subscribeToPush);

On the backend, you’d send push messages via a service like Firebase Cloud Messaging (FCM) or a self-hosted solution. This pattern is production-ready for Android/Chrome and increasingly viable for iOS home-screen installs, but expect inconsistencies.

Background sync and periodic sync

Background sync lets you defer network tasks until connectivity is restored. It’s supported in Chrome on Android. iOS doesn’t support it. The “periodic background sync” API allows scheduled updates for installed PWAs, but it’s limited and requires user permission.

Example: Register a sync tag after a failed API call.

// public/sync.js
async function requestBackgroundSync() {
  const registration = await navigator.serviceWorker.ready;
  if ('sync' in registration) {
    await registration.sync.register('sync-tasks');
    console.log('Background sync registered');
  } else {
    console.warn('Background sync not supported');
  }
}

// In your service worker (sw.js)
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-tasks') {
    event.waitUntil(syncTasks());
  }
});

async function syncTasks() {
  // Try to send pending tasks to server
  const db = await openDB();
  const pending = await db.getAll('pendingTasks');
  for (const item of pending) {
    try {
      const res = await fetch('/api/tasks', {
        method: 'POST',
        body: JSON.stringify(item),
      });
      if (res.ok) await db.delete('pendingTasks', item.id);
    } catch (e) {
      // Keep trying next time
    }
  }
}

This is extremely useful for field apps that lose connectivity, but don’t rely on it if you target iOS or if your tasks must run exactly once.

Storage and data management

IndexedDB is the main client-side database for web apps. It’s transactional and can store structured data. The first time you use it, it feels weird compared to SQLite in native apps, but with good wrappers, it’s robust.

I often use Dexie.js for simpler queries. Here’s a minimal setup:

// public/db.js
import Dexie from 'https://cdn.jsdelivr.net/npm/dexie@3.2.4/dist/dexie.mjs';

export const db = new Dexie('tasksDB');
db.version(1).stores({
  tasks: '++id, title, done, updatedAt',
});

export async function addTask(title) {
  return db.tasks.add({
    title,
    done: false,
    updatedAt: Date.now(),
  });
}

export async function listTasks() {
  return db.tasks.orderBy('updatedAt').reverse().toArray();
}

On mobile, storage can be cleared by the OS when space is low. Consider exporting data to a server or using the File System Access API where available, but note it’s not supported on Safari/iOS.

Hardware access: what’s real today

  • Bluetooth/Web Bluetooth: Supported on Chrome/Edge on desktop and Android. Not supported on iOS or Firefox. Useful for IoT dashboards that connect to BLE devices, but problematic for universal mobile apps.
  • NFC: The Web NFC API is available on Chrome/Android. Not on iOS or Safari. Good for niche industrial apps; not for broad consumer apps.
  • Geolocation and camera: Widely supported. Camera access is more constrained on iOS; file uploads can be tricky.
  • Biometrics/auth: The Web Authentication API (WebAuthn) is available for passkeys and hardware keys, which is excellent for security. It’s supported across modern browsers, but UX varies by platform.

Performance and UX considerations

Startup time and UI responsiveness depend on network conditions and device capabilities. Common patterns:

  • App shell caching to reduce first paint.
  • Preload critical assets and lazy-load the rest.
  • Use requestIdleCallback for non-critical work.
  • For smooth animations, prefer CSS transforms and the compositor; avoid layout thrashing.

Example: Preload critical CSS and defer non-critical JS.

<head>
  <link rel="preload" href="/styles.css" as="style" />
  <link rel="preconnect" href="https://api.example.com" />
</head>
<body>
  <script src="/app.js" defer></script>
</body>

Distribution and updates

  • App stores: PWAs can be distributed via TWA in Google Play, but not Apple’s App Store. Some teams wrap PWAs in native shells (Capacitor, Cordova) to get into stores, but then you’re maintaining a native project and lose many PWA advantages.
  • Updates: Web updates are instant; native updates require store review. For internal tools, web updates are a huge time-saver. For consumer apps, instant updates can be risky if you rely on strict QA gates.

Native mobile experience: Where it still wins

Performance and predictability

Native apps offer consistent performance, especially for graphics-heavy or real-time tasks. Games, video editors, and complex animations benefit from Metal/Vulkan, SIMD, and native threading. On the web, performance is improving with WebAssembly and WebGPU, but cross-device consistency is harder to guarantee.

OS integrations and background work

Native apps can run background tasks more reliably, handle push notifications consistently, and integrate with system features like share sheets, widgets, and lock screen actions. iOS’s background modes (audio, location, bluetooth) and Android’s WorkManager are beyond the reach of PWAs today.

App store discovery and monetization

If your business relies on app store discovery, subscriptions, or in-app purchases, native is often the safer bet. While web payments exist, app stores provide mature billing infrastructure and user trust.

Security and device features

Biometric auth, secure enclaves, hardware-backed keys, and full disk encryption are more accessible in native. WebAuthn and secure cookies do a lot, but platform-level security features still favor native.

Example: React Native as a middle ground

React Native doesn’t match native performance for everything, but it’s a pragmatic choice when you need a single codebase with near-native UI and better hardware access than PWAs.

Example folder structure:

/tasks-app
├── /android
├── /ios
├── /src
│   ├── /components
│   │   └── TaskItem.tsx
│   ├── /screens
│   │   └── Home.tsx
│   ├── /services
│   │   └── api.ts
│   └── App.tsx
├── package.json
├── metro.config.js
└── index.js

Example API service with async patterns and error handling:

// src/services/api.ts
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
});

export async function fetchTasks() {
  try {
    const res = await api.get('/tasks');
    return res.data;
  } catch (e) {
    if (e.code === 'ECONNABORTED') {
      throw new Error('Network timeout');
    }
    throw new Error('Failed to load tasks');
  }
}

export async function createTask(title: string) {
  try {
    const res = await api.post('/tasks', { title });
    return res.data;
  } catch (e) {
    // You might queue offline in a real app
    throw new Error('Unable to create task');
  }
}

This pattern aligns with common mobile UX: optimistic updates, graceful error states, and offline queues. React Native gives you native modules for notifications and background tasks, but you still have to navigate platform-specific APIs.

Honest evaluation: tradeoffs and decision matrix

Strengths of PWAs

  • Single codebase for web and mobile. Fast iteration and A/B testing.
  • Installable without store approval; updates are instant.
  • Lower friction for internal tools and markets with restrictive app stores.
  • Strong SEO benefits; users can discover via search.
  • Good offline experience for read-heavy or cache-friendly apps.

Weaknesses of PWAs

  • Platform inconsistency: iOS lags behind Android in capabilities.
  • Push notifications and background sync are limited or absent on iOS.
  • Hardware APIs (Bluetooth, NFC) not universally supported.
  • Storage can be cleared by OS; large datasets may be problematic.
  • App store monetization and discovery are limited.

Strengths of native

  • Full hardware and OS integration.
  • Consistent performance and UX patterns.
  • App store distribution, subscriptions, and billing.
  • Strong security features and background tasks.
  • Better tooling for profiling and debugging device-specific issues.

Weaknesses of native

  • Higher cost: two codebases or a cross-platform framework with native modules.
  • Slower update cycles due to store reviews.
  • More complex CI/CD and release management.
  • Higher device fragmentation on Android; App Store policies on iOS.

When to choose PWAs

  • Content platforms, e-commerce, news, and blogs where search drives discovery.
  • Internal tools and line-of-business apps with variable device ecosystems.
  • MVPs needing rapid iteration and broad reach.
  • Apps where offline caching is sufficient and push notifications are optional or Android-centric.

When to choose native

  • Apps requiring background tasks, biometric auth, advanced sensors, or real-time audio/video.
  • Games or GPU-heavy experiences.
  • Monetization via subscriptions or in-app purchases within app stores.
  • Finance and healthcare apps with strict security and compliance needs.

A practical decision flow I use:

  1. List must-have features. Mark any that are web-unsupported on your target platforms.
  2. Check your distribution model: search vs store.
  3. Estimate maintenance overhead for two native codebases vs one web codebase.
  4. Prototype the critical user journey as a PWA. If you hit platform walls, pivot to native or a hybrid approach (TWA or Capacitor).
  5. Consider a hybrid: PWA core + native wrapper for specific features. This is common in the field.

Personal experience: lessons learned and common mistakes

I once built a PWA for a field inspection app used by technicians on Android tablets. Offline caching and IndexedDB worked well, and background sync handled intermittent connectivity. The client later asked for iOS support and push notifications. That’s when reality hit: iOS push notifications only work for home-screen installs and are limited, and background sync isn’t supported. We ended up shipping a React Native app for iOS with a shared API layer and kept the PWA for Android and desktop users.

Mistakes I made early:

  • Over-relying on “works on my phone.” Test on mid-range Android and older iOS devices. Service workers fail gracefully, but storage quotas vary widely.
  • Ignoring update UX. Without a clear “refresh to update” prompt, users run stale code.
  • Underestimating iOS Safari quirks. File uploads and camera access have subtle constraints; always test real flows.
  • Assuming push notifications are universal. They’re not.

Moments where PWAs proved invaluable:

  • Rolling out features overnight for a distributed team without app store delays.
  • Rapidly A/B testing UI variants on web vs native builds.
  • Delivering a working offline experience for sales teams in low-connectivity areas.

Moments where native won:

  • Integrating biometric login and secure token storage for a fintech MVP.
  • Shipping a video capture app with consistent background recording.
  • Achieving smooth 60fps animations for a fitness tracker UI.

Getting started: tooling and workflow

PWA development workflow

You don’t need heavy frameworks to build a PWA. A static site with a service worker and manifest is enough. For more complex apps, consider Vite or Next.js for routing and build optimizations.

Example minimal PWA folder structure:

/pwa-app
├── /public
│   ├── index.html
│   ├── sw.js
│   ├── manifest.json
│   ├── /icons
│   │   ├── icon-192.png
│   │   └── icon-512.png
│   └── /assets
│       ├── styles.css
│       └── app.js
├── /src
│   └── main.ts   // optional, if using a build step
└── vite.config.ts // optional

I often start with Vite for fast dev and bundling, but keep the service worker manual for control. For serving and HTTPS (required for service workers), use a local tunnel like ngrok during development.

Native development workflow

For Android, Android Studio and Gradle are the norm. For iOS, Xcode with CocoaPods or Swift Package Manager. For cross-platform, React Native with Metro bundler or Flutter.

Example React Native setup steps (mental model):

  • Initialize the project and connect a device or simulator.
  • Organize your code by feature, not platform, and isolate platform-specific modules.
  • Use TypeScript for better maintainability.
  • For native modules, implement only what’s necessary; wrap third-party SDKs carefully.

Example: A simple native module to check network status (conceptual; actual code depends on platform).

Android (Kotlin):

// NetworkModule.kt
package com.tasksapp

import android.content.Context
import android.net.ConnectivityManager
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise

class NetworkModule(reactContext: ReactContextBaseJavaModule) : ReactContextBaseJavaModule(reactContext) {
    override fun getName() = "NetworkModule"

    @ReactMethod
    fun isOnline(promise: Promise) {
        val cm = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork
        val capabilities = cm.getNetworkCapabilities(network)
        val online = capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false
        promise.resolve(online)
    }
}

Swift (iOS):

// NetworkModule.swift
import Foundation
import Network

@objc(NetworkModule)
class NetworkModule: NSObject {
  @objc func isOnline(_ callback: @escaping RCTResponseSenderBlock) {
    let path = NWPathMonitor()
    path.pathUpdateHandler = { p in
      let online = p.status == .satisfied
      callback([online])
    }
    let queue = DispatchQueue(label: "NetworkMonitor")
    path.start(queue: queue)
  }
}

Bridge these modules in JavaScript and call them from your app when deciding whether to queue operations or attempt live requests.

CI/CD and release management

  • PWA: Deploy via any static host (Netlify, Vercel, S3). Use semantic versioning in your app shell and cache-bust assets on update.
  • Native: Use Fastlane for builds and screenshots, and distribute via TestFlight or Firebase App Distribution. Automate versioning and store metadata.

What makes the web stand out: developer experience and maintainability

The web’s developer experience is unrivaled for speed: instant reload, inspectable everywhere, and a single codebase. Modern frameworks provide patterns for state management and routing that map cleanly to mobile UI flows. The ecosystem is huge; you’ll find a library for almost anything, though quality varies.

Maintainability favors PWAs when your team is small or distributed. A single web codebase reduces coordination overhead and simplifies QA. When you need native features, a modular architecture with shared APIs keeps divergence manageable.

Still, the web’s fragmentation is real. You’ll write platform-specific workarounds for Safari quirks and older Android devices. Build progressive enhancement into your UX: start with core functionality, then layer on advanced features where supported.

Free learning resources

Each resource is focused on real-world implementation and updated as platform capabilities evolve.

Summary: Who should use PWAs and who should go native

Choose PWAs if:

  • You need fast iteration, broad reach, and a single codebase.
  • Your core features work well offline via caching and IndexedDB.
  • Your users discover you via search or internal links, not app stores.
  • Push notifications and background sync are optional or Android-centric for your user base.
  • You’re building content platforms, e-commerce, internal tools, or MVPs.

Choose native if:

  • You need deep hardware integration (sensors, biometrics, Bluetooth/NFC) and consistent background tasks.
  • Your app requires predictable high performance, especially for graphics, audio, or video.
  • You rely on app store monetization, subscriptions, or discovery.
  • You operate in regulated domains like finance or healthcare.
  • Your team can support multiple codebases or a cross-platform framework.

The pragmatic path for many teams is to prototype as a PWA and only add native layers when you hit platform limits. This keeps options open and avoids over-investment early. In practice, PWAs have saved me weeks of release cycles, while native has saved features that wouldn’t have shipped on the web.

If you’re uncertain, build the critical user flow as a PWA first. Measure performance and user feedback. If push notifications or background sync become blockers on iOS, plan a React Native module for those flows. The best choice is the one that aligns with your users and your ability to maintain the product over time.