---
id: web-pwa-service-worker
title: PWA / Service Worker — Offline / Push / Install
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [web, pwa, service-worker, offline, vibe-coding]
tech_stack: { language: "TS / Workbox", applicable_to: ["Frontend"] }
applied_in: []
aliases: [PWA, Service Worker, Workbox, manifest, install prompt, push notification web]
---
# PWA / Service Worker
> **Web app 가 native 처럼**. Manifest + Service Worker = installable + offline + push. **Workbox** 가 표준 라이브러리. iOS 16.4+ web push 지원.
## 📖 핵심 개념
- Manifest: 앱 메타 (이름, icon, theme).
- Service Worker: 네트워크 인터셉트, 캐싱, push.
- Cache API: 자원 저장.
- Background sync: 오프라인 → 온라인 시 sync.
## 💻 코드 패턴
### Manifest
```json
// public/manifest.webmanifest
{
"name": "Acme",
"short_name": "Acme",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
```
```html
```
### Service Worker (단순 cache)
```ts
// public/sw.js
const CACHE = 'app-v1';
const ASSETS = ['/', '/app.css', '/app.js'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request).then(r => r ?? fetch(e.request))
);
});
```
### Register
```ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(reg => {
reg.addEventListener('updatefound', () => {
const w = reg.installing;
w?.addEventListener('statechange', () => {
if (w.state === 'installed' && navigator.serviceWorker.controller) {
showUpdatePrompt(); // 새 버전 있음
}
});
});
});
}
```
### Workbox (high-level)
```ts
// workbox.config.ts (Vite plugin / Next config)
import { generateSW } from 'workbox-build';
generateSW({
swDest: 'dist/sw.js',
globDirectory: 'dist',
globPatterns: ['**/*.{js,css,html,png,jpg,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com/,
handler: 'NetworkFirst',
options: {
cacheName: 'api',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 50, maxAgeSeconds: 5 * 60 },
},
},
{
urlPattern: /\.(?:png|jpg|webp)$/,
handler: 'CacheFirst',
options: { cacheName: 'images', expiration: { maxAgeSeconds: 30 * 24 * 3600 } },
},
],
});
```
### Vite-PWA plugin
```ts
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: { globPatterns: ['**/*.{js,css,html,png,svg}'] },
manifest: { /* 위 */ },
}),
];
```
### Install prompt
```ts
let deferred: BeforeInstallPromptEvent | null = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e as BeforeInstallPromptEvent;
showInstallButton();
});
installBtn.onclick = async () => {
if (!deferred) return;
await deferred.prompt();
const choice = await deferred.userChoice;
console.log(choice.outcome); // 'accepted' / 'dismissed'
deferred = null;
};
```
### Push notification
```ts
// 권한
const perm = await Notification.requestPermission();
if (perm !== 'granted') return;
// Subscribe
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
// 서버에 보내 — DB 에 저장
await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(sub) });
```
```ts
// SW
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge.png',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.url));
});
```
### Server push (web-push)
```ts
import webpush from 'web-push';
webpush.setVapidDetails('mailto:admin@acme.com', VAPID_PUBLIC, VAPID_PRIVATE);
await webpush.sendNotification(subscription, JSON.stringify({
title: 'New message',
body: 'You have a new message',
url: '/inbox',
}));
```
### Background sync
```ts
// SW
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncOrders());
}
});
// App
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-orders');
// 오프라인 → 다음 online 에 자동
```
### Update flow (smooth)
```ts
// 사용자에 새 버전 알림 → "Refresh" 버튼
// 한번에 reload — 데이터 잃지 않게 자동 X
```
```ts
// Workbox skipWaiting
import { Workbox } from 'workbox-window';
const wb = new Workbox('/sw.js');
wb.addEventListener('waiting', () => {
if (confirm('New version available. Refresh?')) {
wb.messageSkipWaiting();
window.location.reload();
}
});
```
## 🤔 의사결정 기준
| 사용 | 추천 |
|---|---|
| Static + cache | CacheFirst |
| API + freshness | NetworkFirst (timeout fallback) |
| Image / font | CacheFirst with TTL |
| Real-time | Network only |
| Offline-first | Workbox + IndexedDB |
| Push | VAPID + web-push |
## ❌ 안티패턴
- **모든 자원 cache + version 안 bump**: 새 버전 안 받음.
- **API 까지 long cache**: stale 데이터.
- **SW 무한 cache 자라남**: expiration / quota.
- **Update 강제 (skipWaiting)**: 진행 중 작업 잃음.
- **Push 권한 즉시 page load 시**: 사용자 거부 + 차단.
- **VAPID key client 노출 (private)**: server only.
- **iOS push 가정 — 안 install**: PWA 설치 후만 push 작동.
## 🤖 LLM 활용 힌트
- Workbox 가 SW 직접 보다 단순.
- VitePWA / next-pwa plugin.
- Push = VAPID + web-push + iOS 설치 필요.
## 🔗 관련 문서
- [[Web_HTTP_Cache_Headers]]
- [[Web_Service_Worker_Patterns]]
- [[iOS_Push_Notifications]]