Skip to content

Split View: PWA 완전 가이드 2025: 네이티브 앱 수준의 웹 경험

|

PWA 완전 가이드 2025: 네이티브 앱 수준의 웹 경험

도입

2025년, PWA(Progressive Web App)는 더 이상 실험적 기술이 아닙니다. Twitter(X), Starbucks, Pinterest, Spotify 등 수많은 대형 서비스가 PWA를 도입하여 네이티브 앱에 버금가는 사용자 경험을 제공하고 있습니다. Google Play Store와 Microsoft Store에서는 PWA를 직접 등록할 수 있으며, iOS도 점진적으로 PWA 지원을 확대하고 있습니다.

이 가이드에서는 Service Worker의 생명주기부터 5가지 캐싱 전략, Workbox 활용, 푸시 알림, 오프라인 우선 아키텍처, Next.js PWA 통합, 그리고 앱스토어 배포까지 체계적으로 다룹니다.

핵심 표현 정리

표현의미
Service Worker브라우저 백그라운드에서 실행되는 JavaScript 프록시. 네트워크 요청 가로채기, 캐싱, 푸시 알림 처리
Cache-First Strategy캐시에서 먼저 응답하고, 없으면 네트워크에서 가져오는 전략. 오프라인 지원의 핵심
Web App ManifestPWA의 메타데이터를 정의하는 JSON 파일. 앱 이름, 아이콘, 시작 URL, 디스플레이 모드 등
WorkboxGoogle이 만든 Service Worker 라이브러리. 캐싱 전략, 프리캐싱, 라우팅을 쉽게 구현

1. PWA란 무엇인가?

1.1 PWA의 핵심 특성

PWA는 웹 기술로 구축되면서도 네이티브 앱과 같은 경험을 제공하는 애플리케이션입니다.

PWA3가지 핵심 요소:

┌─────────────────────────────────────────┐
Progressive Web App├─────────────┬─────────────┬─────────────┤
ReliableFastEngaging  (안정적)       (빠른)     (몰입감)│             │             │             │
- 오프라인  │ - 즉시 로드 │ - 홈화면    │
- 안정적    │ - 부드러운  │ - 전체화면  │
│   네트워크  │   스크롤    │ - 푸시 알림 │
│             │ - 60fps     │ - 설치 가능 │
└─────────────┴─────────────┴─────────────┘

1.2 PWA vs 네이티브 앱 vs 일반 웹

기능일반 웹PWA네이티브 앱
설치 필요없음선택적필수
오프라인 지원없음있음있음
푸시 알림없음있음있음
앱스토어 배포불가가능 (TWA/PWABuilder)기본
업데이트즉시즉시/백그라운드스토어 심사 필요
파일 크기0 (URL 접근)수 KB ~ 수 MB수십~수백 MB
하드웨어 접근제한적확대 중완전
개발 비용낮음낮음높음 (플랫폼별)
SEO우수우수불가
크로스 플랫폼브라우저브라우저플랫폼별 개발

1.3 PWA 도입 성과 사례

주요 기업 PWA 성과:

Twitter Lite:
- 페이지당 데이터 소비 70% 감소
- 트윗 전송 75% 증가
- 이탈률 20% 감소

Pinterest:
- 광고 수익 44% 증가
- 핵심 참여 지표 60% 증가
- 로그인 사용자 40% 증가

Starbucks:
- 네이티브 앱 대비 99.84% 작은 크기
- 일일 활성 사용자 2배 증가

Uber:
- 3G에서 3초 이내 로드
- 핵심 앱 크기 50KB

2. Service Worker 생명주기

2.1 Service Worker란?

Service Worker는 브라우저와 네트워크 사이에서 동작하는 프록시 스크립트입니다. 메인 스레드와 별도의 스레드에서 실행되며, DOM에 직접 접근할 수 없습니다.

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
Web App   │────▶│Service Worker│────▶│   Network  (Main  (Proxy)  (Server)Thread)   │◀────│              │◀────│             │
└─────────────┘     │   ┌──────┐   │     └─────────────┘
                    │   │Cache │   │
                    │   │ API  │   │
                    │   └──────┘   │
                    └──────────────┘

2.2 생명주기 단계

Service Worker 생명주기:

1. Registration (등록)
   └─▶ navigator.serviceWorker.register('/sw.js')

2. Installation (설치)
   └─▶ 'install' 이벤트 발생
   └─▶ 정적 자원 프리캐싱

3. Waiting (대기)
   └─▶ 이전 SW가 활성 상태면 대기

4. Activation (활성화)
   └─▶ 'activate' 이벤트 발생
   └─▶ 오래된 캐시 정리

5. Fetch (요청 가로채기)
   └─▶ 'fetch' 이벤트로 네트워크 요청 제어

6. Idle / Terminated (유휴/종료)
   └─▶ 이벤트 없으면 브라우저가 종료 가능

2.3 기본 Service Worker 구현

// sw.js - Service Worker 파일
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/offline.html',
];

// Install 이벤트: 정적 자원 프리캐싱
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Pre-caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting()) // 대기 건너뛰기
  );
});

// Activate 이벤트: 오래된 캐시 정리
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim()) // 즉시 제어 획득
  );
});

// Fetch 이벤트: 요청 가로채기
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
      .catch(() => {
        // 네트워크 실패 시 오프라인 페이지
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

2.4 등록 코드

// main.js - 앱 진입점
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      console.log('SW registered:', registration.scope);

      // 업데이트 확인
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // 새 버전 사용 가능
              showUpdateNotification();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

function showUpdateNotification() {
  const banner = document.createElement('div');
  banner.innerHTML = '새 버전이 있습니다. <button id="reload">업데이트</button>';
  banner.className = 'update-banner';
  document.body.appendChild(banner);

  document.getElementById('reload').addEventListener('click', () => {
    window.location.reload();
  });
}

3. 캐싱 전략 5가지

3.1 Cache First (캐시 우선)

캐시에 있으면 캐시에서, 없으면 네트워크에서 가져옵니다. 정적 자원에 최적입니다.

// Cache First 전략
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      });
    })
  );
});
요청 흐름:
Client ──▶ Cache ──(hit)──▶ 응답 반환
              
           (miss)
           Network ──▶ 응답 반환 + 캐시 저장

3.2 Network First (네트워크 우선)

네트워크를 먼저 시도하고, 실패하면 캐시에서 가져옵니다. API 데이터에 적합합니다.

// Network First 전략
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

3.3 Stale While Revalidate (오래된 것 사용 후 갱신)

캐시에서 즉시 응답하면서, 백그라운드에서 네트워크로 캐시를 갱신합니다.

// Stale While Revalidate 전략
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cached || fetchPromise;
      });
    })
  );
});

3.4 Cache Only / Network Only

// Cache Only - 프리캐싱된 자원에만 사용
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

// Network Only - 캐싱이 불필요한 요청 (예: 분석 데이터)
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

3.5 전략 선택 가이드

전략사용 사례장점단점
Cache First정적 자원 (CSS, JS, 이미지)빠른 응답, 오프라인업데이트 지연
Network FirstAPI 데이터, 뉴스 피드항상 최신느린 네트워크에서 지연
Stale While Revalidate자주 변하는 정적 자원빠르면서 최신첫 요청은 오래된 데이터
Cache Only버전화된 자원완전한 오프라인업데이트 불가
Network Only결제, 인증항상 최신오프라인 불가

4. Web App Manifest

4.1 Manifest 작성

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An awesome PWA that works offline",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "ko",
  "dir": "ltr",
  "categories": ["productivity", "utilities"],
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
    }
  ]
}

4.2 HTML에 Manifest 연결

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Manifest 연결 -->
  <link rel="manifest" href="/manifest.json">

  <!-- iOS 지원 -->
  <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="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png">

  <!-- 테마 색상 -->
  <meta name="theme-color" content="#2196F3">

  <title>My PWA</title>
</head>
<body>
  <!-- 앱 콘텐츠 -->
</body>
</html>

4.3 Display 모드 비교

모드설명브라우저 UI
fullscreen전체 화면, 상태바 숨김없음
standalone독립 앱처럼 표시상태바만
minimal-ui최소 브라우저 UI뒤로가기/새로고침
browser일반 브라우저 탭전체 브라우저 UI

5. Workbox를 활용한 Service Worker

5.1 Workbox 소개

Workbox는 Google이 만든 Service Worker 라이브러리로, 캐싱 전략을 선언적으로 구현할 수 있습니다.

# Workbox CLI 설치
npm install workbox-cli --save-dev

# 또는 Webpack 플러그인
npm install workbox-webpack-plugin --save-dev

5.2 Workbox로 캐싱 전략 구현

// sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 프리캐싱 (빌드 시 주입)
precacheAndRoute(self.__WB_MANIFEST);

// 이미지: Cache First + 만료 설정
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
      }),
    ],
  })
);

// CSS/JS: Stale While Revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

// API 호출: Network First
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5분
      }),
    ],
  })
);

// Google Fonts: Cache First
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com' ||
               url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1년
      }),
    ],
  })
);

5.3 Workbox 빌드 설정

// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: [
    '**/*.{html,js,css,png,jpg,svg,woff2}'
  ],
  globIgnores: [
    'admin/**/*',
    'node_modules/**/*'
  ],
  swDest: 'dist/sw.js',
  swSrc: 'src/sw.js',
  maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
};

5.4 백그라운드 동기화

// Background Sync로 오프라인 폼 제출
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60, // 24시간 (분 단위)
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request);
        console.log('[BG Sync] Replayed:', entry.request.url);
      } catch (error) {
        console.error('[BG Sync] Failed:', error);
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// POST 요청에 Background Sync 적용
registerRoute(
  ({ url, request }) =>
    url.pathname === '/api/submit' && request.method === 'POST',
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST'
);

6. 푸시 알림 (Push Notification)

6.1 푸시 알림 아키텍처

푸시 알림 흐름:

┌──────────┐   구독    ┌──────────────┐
Client  │─────────▶│  Push Server (Browser) (FCM/APNS)└────┬─────┘          └──────┬───────┘
     │ 구독 정보               │
     │ 전달                    │
     ▼                        │
┌──────────┐   푸시 전송  ◀───┘
AppServer└──────────┘

6.2 구독 구현

// 클라이언트: 푸시 알림 구독
async function subscribePush() {
  // 알림 권한 요청
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;

  // VAPID 공개키 변환
  const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
  const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

  // 구독 생성
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: convertedKey,
  });

  // 서버에 구독 정보 전송
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  console.log('Push subscription successful');
}

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;
}

6.3 서버에서 푸시 전송

// Node.js 서버: web-push 라이브러리
const webpush = require('web-push');

// VAPID 키 설정
webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// 푸시 메시지 전송
async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: '새 메시지',
        body: '김철수님이 메시지를 보냈습니다.',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        data: {
          url: '/messages/123',
          timestamp: Date.now(),
        },
        actions: [
          { action: 'reply', title: '답장', icon: '/icons/reply.png' },
          { action: 'dismiss', title: '닫기', icon: '/icons/close.png' },
        ],
      })
    );
    console.log('Push sent successfully');
  } catch (error) {
    if (error.statusCode === 410) {
      // 구독 만료 - DB에서 제거
      await removeSubscription(subscription.endpoint);
    }
    console.error('Push failed:', error);
  }
}

6.4 Service Worker에서 푸시 수신

// sw.js: 푸시 이벤트 처리
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: data.actions || [],
    tag: data.tag || 'default',
    renotify: true,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

// 알림 클릭 처리
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const urlToOpen = event.notification.data.url || '/';

  if (event.action === 'reply') {
    // 답장 액션 처리
    event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
  } else {
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        // 이미 열린 창이 있으면 포커스
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        // 없으면 새 창 열기
        return clients.openWindow(urlToOpen);
      })
    );
  }
});

7. 오프라인 우선 (Offline-First) 아키텍처

7.1 IndexedDB를 활용한 로컬 데이터 저장

// IndexedDB 래퍼 (idb 라이브러리 사용)
import { openDB } from 'idb';

class LocalStore {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
  }

  async getDB() {
    return openDB(this.dbName, 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('tasks')) {
          const store = db.createObjectStore('tasks', {
            keyPath: 'id',
            autoIncrement: true,
          });
          store.createIndex('status', 'status');
          store.createIndex('syncStatus', 'syncStatus');
          store.createIndex('updatedAt', 'updatedAt');
        }
      },
    });
  }

  async getAll() {
    const db = await this.getDB();
    return db.getAll(this.storeName);
  }

  async get(id) {
    const db = await this.getDB();
    return db.get(this.storeName, id);
  }

  async add(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.add(this.storeName, item);
  }

  async update(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.put(this.storeName, item);
  }

  async delete(id) {
    const db = await this.getDB();
    return db.delete(this.storeName, id);
  }

  async getPendingSync() {
    const db = await this.getDB();
    const tx = db.transaction(this.storeName, 'readonly');
    const index = tx.store.index('syncStatus');
    return index.getAll('pending');
  }
}

// 사용 예시
const taskStore = new LocalStore('myApp', 'tasks');

// 오프라인에서도 작업 추가 가능
await taskStore.add({
  title: '새 할일',
  status: 'active',
  createdAt: Date.now(),
});

7.2 동기화 전략

// 온라인 복구 시 동기화
class SyncManager {
  constructor(localStore, apiUrl) {
    this.localStore = localStore;
    this.apiUrl = apiUrl;
    this.setupListeners();
  }

  setupListeners() {
    window.addEventListener('online', () => this.sync());
  }

  async sync() {
    const pendingItems = await this.localStore.getPendingSync();

    for (const item of pendingItems) {
      try {
        const response = await fetch(this.apiUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(item),
        });

        if (response.ok) {
          item.syncStatus = 'synced';
          await this.localStore.update(item);
        }
      } catch (error) {
        console.error('Sync failed for item:', item.id, error);
      }
    }
  }
}

8. Next.js PWA 통합

8.1 next-pwa 설정

npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  workboxOptions: {
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts',
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 365 * 24 * 60 * 60, // 1년
          },
        },
      },
      {
        urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font\.css)$/i,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-font-assets',
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 7 * 24 * 60 * 60, // 7일
          },
        },
      },
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-image-assets',
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 24 * 60 * 60, // 24시간
          },
        },
      },
      {
        urlPattern: /\/api\/.*$/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'apis',
          networkTimeoutSeconds: 10,
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
    ],
  },
});

module.exports = withPWA({
  // Next.js 설정
  reactStrictMode: true,
});

8.2 App Router에서 Manifest

// app/manifest.ts (Next.js App Router)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  };
}

8.3 오프라인 페이지 처리

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-3xl font-bold mb-4">오프라인 상태입니다</h1>
      <p className="text-gray-600 mb-8">
        인터넷 연결을 확인해 주세요.
      </p>
      <button
        onClick={() => window.location.reload()}
        className="px-6 py-3 bg-blue-500 text-white rounded-lg"
      >
        다시 시도
      </button>
    </div>
  );
}

9. 앱스토어 배포

9.1 Google Play Store (TWA)

Trusted Web Activity(TWA)를 사용하여 PWA를 Google Play Store에 배포할 수 있습니다.

# Bubblewrap으로 TWA 생성
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play 배포 단계:

1. Digital Asset Links 설정
   - /.well-known/assetlinks.json 파일 생성
   - 앱 서명 인증서의 SHA-256 지문 포함

2. TWA 프로젝트 생성
   - Bubblewrap 또는 PWABuilder 사용
   - Android 프로젝트 자동 생성

3. 서명 및 빌드
   - Android keystore 생성
   - APK 또는 AAB 빌드

4. Play Console에 업로드
   - 내부/비공개/프로덕션 트랙 선택
   - 스토어 목록 작성

9.2 Microsoft Store (PWABuilder)

Microsoft Store 배포 단계:

1. PWABuilder (https://www.pwabuilder.com/) 접속
2. PWA URL 입력
3. 점수 확인 및 패키지 다운로드
4. MSIX 패키지 생성
5. Partner Center에서 앱 등록
6. MSIX 패키지 업로드

9.3 iOS (Safari 지원)

iOS PWA 제한사항 (2025년 기준):

지원:
- 홈 화면 추가
- 오프라인 캐싱
- Web App Manifest (일부)
- 기본 푸시 알림 (iOS 16.4+)

제한:
- 백그라운드 동기화 미지원
- 50MB 스토리지 제한
- 서드파티 브라우저에서 PWA 설치 불가
- 일부 Web API 미지원

10. PWA 성능 최적화

10.1 Lighthouse PWA 점검 항목

Lighthouse PWA 체크리스트:

[필수]
- HTTPS 사용
- Service Worker 등록
- 오프라인에서 200 응답
- Web App Manifest 존재
- start_url 설정
- 아이콘 (192px, 512px)
- viewport 메타 태그 설정
- theme-color 메타 태그

[권장]
- HTTPHTTPS로 리다이렉트
- 3G에서 빠른 로드 (10초 이내)
- 커스텀 스플래시 스크린
- 주소표시줄 테마 색상
- 뷰포트에 맞는 콘텐츠
- apple-touch-icon 설정

10.2 캐시 크기 관리

// 캐시 크기 모니터링
async function getCacheSize() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
    return estimate;
  }
}

// 영구 스토리지 요청
async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const granted = await navigator.storage.persist();
    console.log('Persistent storage:', granted ? 'granted' : 'denied');
  }
}

10.3 설치 프롬프트 커스터마이징

// 설치 프롬프트 제어
let deferredPrompt;

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

function showInstallButton() {
  const installBtn = document.getElementById('install-btn');
  installBtn.style.display = 'block';

  installBtn.addEventListener('click', async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const result = await deferredPrompt.userChoice;

    console.log('Install prompt result:', result.outcome);
    deferredPrompt = null;
    installBtn.style.display = 'none';
  });
}

// 설치 완료 감지
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  deferredPrompt = null;
  // 분석 이벤트 전송
  analytics.track('pwa_installed');
});

11. 실전 퀴즈

각 문제를 풀고 답을 확인해 보세요.

Q1. Service Worker의 생명주기 단계를 순서대로 나열하세요.

Registration - Installation - Waiting - Activation - Fetch (Idle/Terminated)

등록(Registration) 후 설치(Installation)에서 정적 자원을 프리캐싱하고, 이전 SW가 있으면 대기(Waiting) 상태에 들어갑니다. 활성화(Activation)에서 오래된 캐시를 정리하고, 이후 Fetch 이벤트로 네트워크 요청을 제어합니다.

Q2. Cache-First 전략이 가장 적합한 리소스 유형은?

정적 자원(CSS, JS 번들, 이미지, 폰트)

변경 빈도가 낮고 버전 관리가 되는 자원에 적합합니다. 캐시에서 즉시 응답하므로 빠르고 오프라인에서도 동작합니다. API 데이터처럼 자주 변하는 데이터에는 Network First나 Stale While Revalidate가 적합합니다.

Q3. Web App Manifest에서 display: standalone의 의미는?

독립 앱처럼 표시되며 브라우저 주소창이 없는 모드

standalone 모드에서는 상태바만 표시되고, 일반 앱처럼 보입니다. fullscreen은 상태바도 없고, minimal-ui는 최소한의 브라우저 UI(뒤로가기 등)가 표시되며, browser는 일반 브라우저 탭에서 열립니다.

Q4. Workbox의 BackgroundSyncPlugin의 용도는?

오프라인 상태에서 실패한 네트워크 요청을 큐에 저장하고, 온라인 복구 시 자동 재시도하는 것

예를 들어 오프라인 상태에서 폼 제출을 하면, 요청을 IndexedDB에 저장해두었다가 네트워크가 복구되면 자동으로 서버에 전송합니다. 이를 통해 오프라인에서도 데이터 손실 없이 작업할 수 있습니다.

Q5. PWA를 Google Play Store에 배포하기 위한 기술은?

TWA (Trusted Web Activity)

TWA는 Chrome Custom Tabs를 기반으로 PWA를 네이티브 Android 앱으로 래핑합니다. Digital Asset Links 파일(assetlinks.json)을 통해 앱과 웹사이트 간의 소유권을 증명합니다. Bubblewrap이나 PWABuilder 도구로 TWA 프로젝트를 쉽게 생성할 수 있습니다.


12. 참고 자료

  1. web.dev PWA 가이드 - Google의 공식 PWA 가이드
  2. MDN Service Worker API - Service Worker API 문서
  3. Workbox 공식 문서 - Workbox 라이브러리 문서
  4. PWABuilder - PWA 빌드 및 배포 도구
  5. Web Push Protocol - 웹 푸시 알림 개요
  6. Lighthouse - PWA 감사 도구
  7. next-pwa - Next.js PWA 플러그인
  8. idb - IndexedDB Promise 래퍼
  9. Project Fugu - 웹 플랫폼 API 트래커
  10. Bubblewrap - TWA 빌드 도구
  11. What PWA Can Do Today - PWA 기능 데모
  12. Microsoft PWA 문서 - Microsoft Edge PWA 가이드
  13. Apple Developer - Web Apps - iOS PWA 문서

PWA Complete Guide 2025: Native-Quality Web Experiences

Introduction

In 2025, PWA (Progressive Web App) is no longer an experimental technology. Major services like Twitter (X), Starbucks, Pinterest, and Spotify have adopted PWAs to deliver user experiences rivaling native apps. Google Play Store and Microsoft Store allow direct PWA listings, and iOS is progressively expanding PWA support.

This guide systematically covers Service Worker lifecycle, 5 caching strategies, Workbox usage, push notifications, offline-first architecture, Next.js PWA integration, and app store distribution.

Key Expressions

ExpressionMeaning
Service WorkerJavaScript proxy running in browser background. Intercepts network requests, handles caching and push notifications
Cache-First StrategyResponds from cache first, falls back to network. Core of offline support
Web App ManifestJSON file defining PWA metadata. App name, icons, start URL, display mode, etc.
WorkboxGoogle's Service Worker library. Simplifies caching strategies, precaching, and routing

1. What Is a PWA?

1.1 Core Characteristics of PWA

A PWA is an application built with web technologies that delivers native app-like experiences.

Three Core Pillars of PWA:

┌─────────────────────────────────────────┐
Progressive Web App├─────────────┬─────────────┬─────────────┤
ReliableFastEngaging│             │             │             │
- Offline- Instant- Home- Stable    │   load      │   screen    │
│   network   │ - Smooth- Fullscreen│
│             │   scroll    │ - Push│             │ - 60fps     │ - Installable│
└─────────────┴─────────────┴─────────────┘

1.2 PWA vs Native App vs Regular Web

FeatureRegular WebPWANative App
InstallationNoneOptionalRequired
Offline SupportNoneYesYes
Push NotificationsNoneYesYes
App StoreNoYes (TWA/PWABuilder)Default
UpdatesInstantInstant/BackgroundStore review
File Size0 (URL access)KB to MBTens to hundreds of MB
Hardware AccessLimitedExpandingFull
Development CostLowLowHigh (per platform)
SEOExcellentExcellentNone
Cross PlatformBrowserBrowserPlatform-specific

1.3 PWA Success Stories

Major Company PWA Results:

Twitter Lite:
- 70% reduction in data consumption per page
- 75% increase in tweets sent
- 20% reduction in bounce rate

Pinterest:
- 44% increase in ad revenue
- 60% increase in core engagement
- 40% increase in logged-in users

Starbucks:
- 99.84% smaller than native app
- 2x increase in daily active users

Uber:
- Loads in 3 seconds on 3G
- Core app size just 50KB

2. Service Worker Lifecycle

2.1 What Is a Service Worker?

A Service Worker is a proxy script that operates between the browser and the network. It runs on a separate thread from the main thread and cannot directly access the DOM.

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
Web App   │────▶│Service Worker│────▶│   Network  (Main  (Proxy)  (Server)Thread)   │◀────│              │◀────│             │
└─────────────┘     │   ┌──────┐   │     └─────────────┘
                    │   │Cache │   │
                    │   │ API  │   │
                    │   └──────┘   │
                    └──────────────┘

2.2 Lifecycle Stages

Service Worker Lifecycle:

1. Registration
   └─▶ navigator.serviceWorker.register('/sw.js')

2. Installation
   └─▶ 'install' event fires
   └─▶ Precache static assets

3. Waiting
   └─▶ Waits if previous SW is active

4. Activation
   └─▶ 'activate' event fires
   └─▶ Clean up old caches

5. Fetch
   └─▶ Control network requests via 'fetch' event

6. Idle / Terminated
   └─▶ Browser can terminate when no events

2.3 Basic Service Worker Implementation

// sw.js - Service Worker file
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/offline.html',
];

// Install event: precache static assets
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Pre-caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting())
  );
});

// Activate event: clean old caches
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim())
  );
});

// Fetch event: intercept requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
      .catch(() => {
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

2.4 Registration Code

// main.js - App entry point
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      console.log('SW registered:', registration.scope);

      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              showUpdateNotification();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

function showUpdateNotification() {
  const banner = document.createElement('div');
  banner.innerHTML = 'New version available. <button id="reload">Update</button>';
  banner.className = 'update-banner';
  document.body.appendChild(banner);

  document.getElementById('reload').addEventListener('click', () => {
    window.location.reload();
  });
}

3. Five Caching Strategies

3.1 Cache First

Serve from cache if available, otherwise fetch from network. Optimal for static assets.

// Cache First strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      });
    })
  );
});
Request flow:
Client ──▶ Cache ──(hit)──▶ Return response
              
           (miss)
           Network ──▶ Return response + Store in cache

3.2 Network First

Try network first, fall back to cache on failure. Ideal for API data.

// Network First strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

3.3 Stale While Revalidate

Respond immediately from cache while updating the cache from network in background.

// Stale While Revalidate strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cached || fetchPromise;
      });
    })
  );
});

3.4 Cache Only / Network Only

// Cache Only - only for precached resources
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

// Network Only - no caching needed (e.g., analytics)
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

3.5 Strategy Selection Guide

StrategyUse CaseProsCons
Cache FirstStatic assets (CSS, JS, images)Fast response, offlineDelayed updates
Network FirstAPI data, news feedsAlways freshSlow on poor network
Stale While RevalidateFrequently changing static assetsFast and freshFirst request may be stale
Cache OnlyVersioned resourcesFull offlineCannot update
Network OnlyPayments, authAlways currentNo offline

4. Web App Manifest

4.1 Writing the Manifest

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An awesome PWA that works offline",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "en",
  "dir": "ltr",
  "categories": ["productivity", "utilities"],
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
    }
  ]
}

4.2 Linking Manifest in HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Manifest link -->
  <link rel="manifest" href="/manifest.json">

  <!-- iOS support -->
  <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="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png">

  <!-- Theme color -->
  <meta name="theme-color" content="#2196F3">

  <title>My PWA</title>
</head>
<body>
  <!-- App content -->
</body>
</html>

4.3 Display Modes Comparison

ModeDescriptionBrowser UI
fullscreenFull screen, status bar hiddenNone
standaloneDisplayed as standalone appStatus bar only
minimal-uiMinimal browser UIBack/Refresh buttons
browserRegular browser tabFull browser UI

5. Service Worker with Workbox

5.1 Workbox Introduction

Workbox is a Service Worker library by Google that lets you implement caching strategies declaratively.

# Install Workbox CLI
npm install workbox-cli --save-dev

# Or Webpack plugin
npm install workbox-webpack-plugin --save-dev

5.2 Implementing Caching Strategies with Workbox

// sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precaching (injected at build time)
precacheAndRoute(self.__WB_MANIFEST);

// Images: Cache First + expiration
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// CSS/JS: Stale While Revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

// API calls: Network First
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  })
);

// Google Fonts: Cache First
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com' ||
               url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
      }),
    ],
  })
);

5.3 Background Sync

// Background Sync for offline form submission
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60, // 24 hours (in minutes)
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request);
        console.log('[BG Sync] Replayed:', entry.request.url);
      } catch (error) {
        console.error('[BG Sync] Failed:', error);
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// Apply Background Sync to POST requests
registerRoute(
  ({ url, request }) =>
    url.pathname === '/api/submit' && request.method === 'POST',
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST'
);

6. Push Notifications

6.1 Push Notification Architecture

Push Notification Flow:

┌──────────┐  Subscribe ┌──────────────┐
Client  │──────────▶│  Push Server (Browser) (FCM/APNS)└────┬─────┘           └──────┬───────┘
Send subscription       │
     │ info                    │
     ▼                        │
┌──────────┐  Send push  ◀───┘
AppServer└──────────┘

6.2 Subscription Implementation

// Client: Subscribe to push notifications
async function subscribePush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;

  const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
  const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: convertedKey,
  });

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

  console.log('Push subscription successful');
}

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;
}

6.3 Server-Side Push Sending

// Node.js server: web-push library
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'New Message',
        body: 'John sent you a message.',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        data: {
          url: '/messages/123',
          timestamp: Date.now(),
        },
        actions: [
          { action: 'reply', title: 'Reply', icon: '/icons/reply.png' },
          { action: 'dismiss', title: 'Dismiss', icon: '/icons/close.png' },
        ],
      })
    );
    console.log('Push sent successfully');
  } catch (error) {
    if (error.statusCode === 410) {
      await removeSubscription(subscription.endpoint);
    }
    console.error('Push failed:', error);
  }
}

6.4 Receiving Push in Service Worker

// sw.js: Handle push events
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: data.actions || [],
    tag: data.tag || 'default',
    renotify: true,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const urlToOpen = event.notification.data.url || '/';

  if (event.action === 'reply') {
    event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
  } else {
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(urlToOpen);
      })
    );
  }
});

7. Offline-First Architecture

7.1 Local Data Storage with IndexedDB

// IndexedDB wrapper (using idb library)
import { openDB } from 'idb';

class LocalStore {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
  }

  async getDB() {
    return openDB(this.dbName, 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('tasks')) {
          const store = db.createObjectStore('tasks', {
            keyPath: 'id',
            autoIncrement: true,
          });
          store.createIndex('status', 'status');
          store.createIndex('syncStatus', 'syncStatus');
          store.createIndex('updatedAt', 'updatedAt');
        }
      },
    });
  }

  async getAll() {
    const db = await this.getDB();
    return db.getAll(this.storeName);
  }

  async add(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.add(this.storeName, item);
  }

  async update(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.put(this.storeName, item);
  }

  async getPendingSync() {
    const db = await this.getDB();
    const tx = db.transaction(this.storeName, 'readonly');
    const index = tx.store.index('syncStatus');
    return index.getAll('pending');
  }
}

const taskStore = new LocalStore('myApp', 'tasks');
await taskStore.add({
  title: 'New task',
  status: 'active',
  createdAt: Date.now(),
});

7.2 Sync Strategy

// Sync when coming back online
class SyncManager {
  constructor(localStore, apiUrl) {
    this.localStore = localStore;
    this.apiUrl = apiUrl;
    this.setupListeners();
  }

  setupListeners() {
    window.addEventListener('online', () => this.sync());
  }

  async sync() {
    const pendingItems = await this.localStore.getPendingSync();

    for (const item of pendingItems) {
      try {
        const response = await fetch(this.apiUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(item),
        });

        if (response.ok) {
          item.syncStatus = 'synced';
          await this.localStore.update(item);
        }
      } catch (error) {
        console.error('Sync failed for item:', item.id, error);
      }
    }
  }
}

8. Next.js PWA Integration

8.1 next-pwa Setup

npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
  cacheOnFrontEndNav: true,
  workboxOptions: {
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts',
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-image-assets',
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\/api\/.*$/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'apis',
          networkTimeoutSeconds: 10,
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
    ],
  },
});

module.exports = withPWA({
  reactStrictMode: true,
});

8.2 App Router Manifest

// app/manifest.ts (Next.js App Router)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  };
}

8.3 Offline Page Handling

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-3xl font-bold mb-4">You are offline</h1>
      <p className="text-gray-600 mb-8">
        Please check your internet connection.
      </p>
      <button
        onClick={() => window.location.reload()}
        className="px-6 py-3 bg-blue-500 text-white rounded-lg"
      >
        Try Again
      </button>
    </div>
  );
}

9. App Store Distribution

9.1 Google Play Store (TWA)

You can publish PWAs to the Google Play Store using Trusted Web Activity (TWA).

# Create TWA with Bubblewrap
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play Deployment Steps:

1. Set up Digital Asset Links
   - Create /.well-known/assetlinks.json file
   - Include SHA-256 fingerprint of app signing certificate

2. Create TWA project
   - Use Bubblewrap or PWABuilder
   - Auto-generates Android project

3. Sign and build
   - Generate Android keystore
   - Build APK or AAB

4. Upload to Play Console
   - Select internal/closed/production track
   - Complete store listing

9.2 Microsoft Store (PWABuilder)

Microsoft Store Deployment Steps:

1. Visit PWABuilder (https://www.pwabuilder.com/)
2. Enter PWA URL
3. Review score and download package
4. Generate MSIX package
5. Register app in Partner Center
6. Upload MSIX package

9.3 iOS (Safari Support)

iOS PWA Limitations (2025):

Supported:
- Add to Home Screen
- Offline caching
- Web App Manifest (partial)
- Basic push notifications (iOS 16.4+)

Limited:
- No Background Sync
- 50MB storage limit
- Cannot install PWA from third-party browsers
- Some Web APIs not supported

10. PWA Performance Optimization

10.1 Lighthouse PWA Checklist

Lighthouse PWA Checklist:

[Required]
- Uses HTTPS
- Service Worker registered
- Responds with 200 when offline
- Web App Manifest present
- start_url set
- Icons (192px, 512px)
- viewport meta tag
- theme-color meta tag

[Recommended]
- Redirect HTTP to HTTPS
- Fast load on 3G (under 10s)
- Custom splash screen
- Address bar theme color
- Content sized to viewport
- apple-touch-icon set

10.2 Cache Size Management

// Monitor cache size
async function getCacheSize() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
    return estimate;
  }
}

// Request persistent storage
async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const granted = await navigator.storage.persist();
    console.log('Persistent storage:', granted ? 'granted' : 'denied');
  }
}

10.3 Custom Install Prompt

// Control install prompt
let deferredPrompt;

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

function showInstallButton() {
  const installBtn = document.getElementById('install-btn');
  installBtn.style.display = 'block';

  installBtn.addEventListener('click', async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const result = await deferredPrompt.userChoice;

    console.log('Install prompt result:', result.outcome);
    deferredPrompt = null;
    installBtn.style.display = 'none';
  });
}

// Detect installation
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  deferredPrompt = null;
  analytics.track('pwa_installed');
});

11. Practice Quiz

Work through each problem and check your answers.

Q1. List the Service Worker lifecycle stages in order.

Registration - Installation - Waiting - Activation - Fetch (Idle/Terminated)

After Registration, the Installation stage precaches static assets. If a previous SW exists, it enters the Waiting state. Activation cleans up old caches, and then Fetch events control network requests.

Q2. What resource type is Cache-First strategy best suited for?

Static assets (CSS, JS bundles, images, fonts)

Best for resources with low change frequency and version management. Responds instantly from cache and works offline. For frequently changing data like API responses, use Network First or Stale While Revalidate instead.

Q3. What does display: standalone mean in Web App Manifest?

Displayed as a standalone app without the browser address bar

In standalone mode, only the status bar is shown, making it look like a native app. fullscreen hides even the status bar, minimal-ui shows minimal browser UI (back button, etc.), and browser opens in a regular browser tab.

Q4. What is the purpose of Workbox's BackgroundSyncPlugin?

Stores failed network requests in a queue while offline and automatically retries them when connectivity is restored

For example, if a form submission occurs offline, the request is saved to IndexedDB and automatically sent to the server when the network recovers. This enables data-loss-free operation even while offline.

Q5. What technology is used to publish a PWA on the Google Play Store?

TWA (Trusted Web Activity)

TWA wraps a PWA as a native Android app based on Chrome Custom Tabs. A Digital Asset Links file (assetlinks.json) proves ownership between the app and website. Tools like Bubblewrap or PWABuilder make it easy to create TWA projects.


12. References

  1. web.dev PWA Guide - Google's official PWA guide
  2. MDN Service Worker API - Service Worker API documentation
  3. Workbox Documentation - Workbox library docs
  4. PWABuilder - PWA build and deployment tool
  5. Web Push Protocol - Web push notification overview
  6. Lighthouse - PWA audit tool
  7. next-pwa - Next.js PWA plugin
  8. idb - IndexedDB Promise wrapper
  9. Project Fugu - Web platform API tracker
  10. Bubblewrap - TWA build tool
  11. What PWA Can Do Today - PWA capability demos
  12. Microsoft PWA Docs - Microsoft Edge PWA guide
  13. Apple Developer - Web Apps - iOS PWA documentation