Split View: PWA 완전 가이드 2025: 네이티브 앱 수준의 웹 경험
PWA 완전 가이드 2025: 네이티브 앱 수준의 웹 경험
- 도입
- 1. PWA란 무엇인가?
- 2. Service Worker 생명주기
- 3. 캐싱 전략 5가지
- 4. Web App Manifest
- 5. Workbox를 활용한 Service Worker
- 6. 푸시 알림 (Push Notification)
- 7. 오프라인 우선 (Offline-First) 아키텍처
- 8. Next.js PWA 통합
- 9. 앱스토어 배포
- 10. PWA 성능 최적화
- 11. 실전 퀴즈
- 12. 참고 자료
도입
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 Manifest PWA의 메타데이터를 정의하는 JSON 파일. 앱 이름, 아이콘, 시작 URL, 디스플레이 모드 등 Workbox Google이 만든 Service Worker 라이브러리. 캐싱 전략, 프리캐싱, 라우팅을 쉽게 구현
1. PWA란 무엇인가?
1.1 PWA의 핵심 특성
PWA는 웹 기술로 구축되면서도 네이티브 앱과 같은 경험을 제공하는 애플리케이션입니다.
PWA의 3가지 핵심 요소:
┌─────────────────────────────────────────┐
│ Progressive Web App │
├─────────────┬─────────────┬─────────────┤
│ Reliable │ Fast │ Engaging │
│ (안정적) │ (빠른) │ (몰입감) │
│ │ │ │
│ - 오프라인 │ - 즉시 로드 │ - 홈화면 │
│ - 안정적 │ - 부드러운 │ - 전체화면 │
│ 네트워크 │ 스크롤 │ - 푸시 알림 │
│ │ - 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 First | API 데이터, 뉴스 피드 | 항상 최신 | 느린 네트워크에서 지연 |
| 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) │
└────┬─────┘ └──────┬───────┘
│ 구독 정보 │
│ 전달 │
▼ │
┌──────────┐ 푸시 전송 ◀───┘
│ App │
│ Server │
└──────────┘
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 메타 태그
[권장]
- HTTP를 HTTPS로 리다이렉트
- 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. 참고 자료
- web.dev PWA 가이드 - Google의 공식 PWA 가이드
- MDN Service Worker API - Service Worker API 문서
- Workbox 공식 문서 - Workbox 라이브러리 문서
- PWABuilder - PWA 빌드 및 배포 도구
- Web Push Protocol - 웹 푸시 알림 개요
- Lighthouse - PWA 감사 도구
- next-pwa - Next.js PWA 플러그인
- idb - IndexedDB Promise 래퍼
- Project Fugu - 웹 플랫폼 API 트래커
- Bubblewrap - TWA 빌드 도구
- What PWA Can Do Today - PWA 기능 데모
- Microsoft PWA 문서 - Microsoft Edge PWA 가이드
- Apple Developer - Web Apps - iOS PWA 문서
PWA Complete Guide 2025: Native-Quality Web Experiences
- Introduction
- 1. What Is a PWA?
- 2. Service Worker Lifecycle
- 3. Five Caching Strategies
- 4. Web App Manifest
- 5. Service Worker with Workbox
- 6. Push Notifications
- 7. Offline-First Architecture
- 8. Next.js PWA Integration
- 9. App Store Distribution
- 10. PWA Performance Optimization
- 11. Practice Quiz
- 12. References
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
Expression Meaning Service Worker JavaScript proxy running in browser background. Intercepts network requests, handles caching and push notifications Cache-First Strategy Responds from cache first, falls back to network. Core of offline support Web App Manifest JSON file defining PWA metadata. App name, icons, start URL, display mode, etc. Workbox Google'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 │
├─────────────┬─────────────┬─────────────┤
│ Reliable │ Fast │ Engaging │
│ │ │ │
│ - Offline │ - Instant │ - Home │
│ - Stable │ load │ screen │
│ network │ - Smooth │ - Fullscreen│
│ │ scroll │ - Push │
│ │ - 60fps │ - Installable│
└─────────────┴─────────────┴─────────────┘
1.2 PWA vs Native App vs Regular Web
| Feature | Regular Web | PWA | Native App |
|---|---|---|---|
| Installation | None | Optional | Required |
| Offline Support | None | Yes | Yes |
| Push Notifications | None | Yes | Yes |
| App Store | No | Yes (TWA/PWABuilder) | Default |
| Updates | Instant | Instant/Background | Store review |
| File Size | 0 (URL access) | KB to MB | Tens to hundreds of MB |
| Hardware Access | Limited | Expanding | Full |
| Development Cost | Low | Low | High (per platform) |
| SEO | Excellent | Excellent | None |
| Cross Platform | Browser | Browser | Platform-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
| Strategy | Use Case | Pros | Cons |
|---|---|---|---|
| Cache First | Static assets (CSS, JS, images) | Fast response, offline | Delayed updates |
| Network First | API data, news feeds | Always fresh | Slow on poor network |
| Stale While Revalidate | Frequently changing static assets | Fast and fresh | First request may be stale |
| Cache Only | Versioned resources | Full offline | Cannot update |
| Network Only | Payments, auth | Always current | No 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
| Mode | Description | Browser UI |
|---|---|---|
fullscreen | Full screen, status bar hidden | None |
standalone | Displayed as standalone app | Status bar only |
minimal-ui | Minimal browser UI | Back/Refresh buttons |
browser | Regular browser tab | Full 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 ◀───┘
│ App │
│ Server │
└──────────┘
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
- web.dev PWA Guide - Google's official PWA guide
- MDN Service Worker API - Service Worker API documentation
- Workbox Documentation - Workbox library docs
- PWABuilder - PWA build and deployment tool
- Web Push Protocol - Web push notification overview
- Lighthouse - PWA audit tool
- next-pwa - Next.js PWA plugin
- idb - IndexedDB Promise wrapper
- Project Fugu - Web platform API tracker
- Bubblewrap - TWA build tool
- What PWA Can Do Today - PWA capability demos
- Microsoft PWA Docs - Microsoft Edge PWA guide
- Apple Developer - Web Apps - iOS PWA documentation