Skip to content

Dev PWA — biến website thành App thật sự

Cập nhật 2026

  • PWA Renaissance sau iOS 17.4 EU Digital Markets Act — Apple bắt buộc cho PWA install như app
  • WebAPK trên Android — Chrome tự tạo APK shell cho PWA, install như app native
  • Service Worker v2 với better caching và offline
  • Push API giờ work trên iOS Safari (17.4+)
  • WebAssembly + WebGPU cho heavy compute trong browser

PWA là gì?

PWA = Progressive Web App = website "tiến hoá" thành app:

  • Installable: user click "Add to Home Screen" → icon trên màn hình như app
  • Offline: chạy được khi không có internet (cache static + dynamic)
  • Push notification: notify user dù app closed
  • Full screen: không thanh address browser, trải nghiệm như native
  • Background sync: sync data khi mạng có lại

PWA vs Native App

Tiêu chíPWANative (iOS/Android)
InstallURL → "Add to Home Screen"App Store / Play Store
DiscoveryGoogle searchApp Store ASO
UpdateTự động (refresh)Qua store
OfflineCó (qua Service Worker)
Push notificationCó (Web Push)Có (APNs/FCM)
Hardware accessLimitedFull
PerformanceTốtTốt nhất
Dev cost1 codebase webiOS + Android riêng
App Store presenceNo
Best forContent, e-commerce, productivityGame, AR/VR, deep OS integration

Use case phù hợp PWA

  • News site (VnExpress, Tuổi Trẻ)
  • E-commerce (Shopee Lite, Lazada Lite)
  • Productivity (Notion, Todoist)
  • Social (Twitter Lite, Pinterest)
  • Editor (Figma, Canva)
  • Lưu ý: KHÔNG phù hợp game phức tạp hoặc AR

Chương 1: dựng PWA cơ bản

1.1 Project structure

my-pwa/
├── public/
│   ├── manifest.json
│   ├── service-worker.js
│   ├── icons/
│   │   ├── icon-192.png
│   │   └── icon-512.png
│   └── offline.html
├── src/
│   ├── App.tsx
│   └── main.tsx
├── index.html
└── vite.config.ts

1.2 Setup project với Vite + React

bash
npm create vite@latest my-pwa -- --template react-ts
cd my-pwa
npm install
npm install vite-plugin-pwa --save-dev

1.3 vite.config.ts

typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'icons/*.png'],
      manifest: {
        name: 'My First PWA',
        short_name: 'MyPWA',
        description: 'A progressive web app demo',
        theme_color: '#4F46E5',
        background_color: '#ffffff',
        display: 'standalone',
        orientation: 'portrait',
        scope: '/',
        start_url: '/',
        icons: [
          {
            src: 'icons/icon-192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'icons/icon-512.png',
            sizes: '512x512',
            type: 'image/png'
          },
          {
            src: 'icons/icon-512.png',
            sizes: '512x512',
            type: 'image/png',
            purpose: 'maskable'
          }
        ]
      },
      workbox: {
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\..*\..*$/,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 60 * 60 * 24 // 1 day
              }
            }
          },
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'image-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
              }
            }
          }
        ]
      }
    })
  ]
})

1.4 App.tsx cơ bản

tsx
import { useState, useEffect } from 'react'

function App() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)
  const [installPrompt, setInstallPrompt] = useState<any>(null)
  
  useEffect(() => {
    const handleOnline = () => setIsOnline(true)
    const handleOffline = () => setIsOnline(false)
    
    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)
    
    // Listen install prompt
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      setInstallPrompt(e)
    })
    
    return () => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    }
  }, [])
  
  const handleInstall = async () => {
    if (!installPrompt) return
    installPrompt.prompt()
    const { outcome } = await installPrompt.userChoice
    console.log('Install outcome:', outcome)
    setInstallPrompt(null)
  }
  
  return (
    <div style={{ padding: 20 }}>
      <h1>My First PWA 🚀</h1>
      <p>Status: {isOnline ? '🟢 Online' : '🔴 Offline'}</p>
      
      {installPrompt && (
        <button onClick={handleInstall}>
          📲 Install App
        </button>
      )}
      
      <h2>Content</h2>
      <p>Đây là content của PWA. Thử tắt internet để xem nó vẫn chạy.</p>
    </div>
  )
}

export default App

1.5 Build và test

bash
npm run build
npm run preview

Mở browser, F12 → Application → Service Workers → check registered.

Chương 2: Service Worker

2.1 Tự viết Service Worker (nếu không dùng plugin)

public/service-worker.js:

javascript
const CACHE_NAME = 'my-pwa-v1'
const ASSETS = [
  '/',
  '/index.html',
  '/manifest.json',
  '/offline.html'
]

// Install: pre-cache assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
  )
})

// Activate: clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      )
    )
  )
})

// Fetch: cache-first strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) return response
      
      return fetch(event.request)
        .then((response) => {
          if (!response || response.status !== 200) return response
          
          const responseClone = response.clone()
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone)
          })
          
          return response
        })
        .catch(() => caches.match('/offline.html'))
    })
  )
})

Register trong index.html:

javascript
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(reg => console.log('SW registered', reg))
      .catch(err => console.error('SW registration failed', err))
  })
}

2.2 Caching strategy

StrategyUse caseBehavior
Cache FirstStatic assets (CSS, JS, image)Check cache → fallback network
Network FirstAPI dataCheck network → fallback cache
Stale While RevalidateContent semi-freshReturn cache, update background
Cache OnlyTruly staticAlways cache
Network OnlyReal-time (chat, stock)Always network

2.3 offline.html

html
<!DOCTYPE html>
<html>
<head>
  <title>Offline</title>
  <style>
    body { font-family: -apple-system, sans-serif; padding: 40px; text-align: center; }
    h1 { color: #4F46E5; }
  </style>
</head>
<body>
  <h1>📡 Bạn đang offline</h1>
  <p>Không có internet. Một số tính năng có thể không dùng được.</p>
  <button onclick="location.reload()">Thử lại</button>
</body>
</html>

Chương 3: Push notification

3.1 Request permission

typescript
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    alert('Browser không support notification')
    return
  }
  
  const permission = await Notification.requestPermission()
  if (permission === 'granted') {
    console.log('Permission granted')
    await subscribeToPush()
  }
}

async function subscribeToPush() {
  const reg = await navigator.serviceWorker.ready
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
  })
  
  // Send subscription tới backend
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  })
}

function urlBase64ToUint8Array(base64String: string) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const rawData = atob(base64)
  return new Uint8Array([...rawData].map(c => c.charCodeAt(0)))
}

3.2 Service Worker handle push

javascript
// service-worker.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {}
  
  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: data.url,
      actions: [
        { action: 'view', title: 'Xem' },
        { action: 'dismiss', title: 'Bỏ qua' }
      ]
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  
  if (event.action === 'view' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data || '/')
    )
  }
})

3.3 Backend gửi push

Node.js với web-push:

javascript
const webpush = require('web-push')

webpush.setVapidDetails(
  'mailto:you@example.com',
  PUBLIC_VAPID_KEY,
  PRIVATE_VAPID_KEY
)

// Gửi notification
async function sendNotification(subscription, data) {
  await webpush.sendNotification(
    subscription,
    JSON.stringify({
      title: 'Hello',
      body: 'Bạn có message mới',
      url: '/messages'
    })
  )
}

Chương 4: IndexedDB cho data lớn

typescript
import { openDB } from 'idb'

const db = await openDB('my-pwa-db', 1, {
  upgrade(db) {
    db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true })
  }
})

// Save
await db.add('notes', { title: 'My note', content: '...', date: new Date() })

// Read
const allNotes = await db.getAll('notes')

Chương 5: Background Sync

Khi user offline, queue request → khi online sync:

typescript
// Component
async function saveNoteOffline(note: Note) {
  const reg = await navigator.serviceWorker.ready
  
  // Save vào IndexedDB
  await db.add('pending-notes', note)
  
  // Register sync
  await reg.sync.register('sync-notes')
}
javascript
// service-worker.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-notes') {
    event.waitUntil(syncNotes())
  }
})

async function syncNotes() {
  const db = await openIDB()
  const pending = await db.getAll('pending-notes')
  
  for (const note of pending) {
    try {
      await fetch('/api/notes', {
        method: 'POST',
        body: JSON.stringify(note)
      })
      await db.delete('pending-notes', note.id)
    } catch (err) {
      // Retry next sync
    }
  }
}

Chương 6: deploy + install

6.1 Deploy

Best hosts cho PWA (HTTPS auto):

  • Vercel (free, fastest)
  • Netlify (free)
  • Cloudflare Pages (free, edge)
  • GitHub Pages (free, hơi slow)
bash
# Vercel
npm install -g vercel
vercel deploy --prod

6.2 Test install trên devices

Android Chrome:

  • Mở URL → menu (3 chấm) → "Install app"
  • Tự popup "Add to Home Screen" nếu PWA đủ tiêu chuẩn

iOS Safari (16.4+):

  • Mở URL → Share button → "Add to Home Screen"
  • iOS 17.4 EU: install như app native với WebAPK-like

Desktop Chrome/Edge:

  • Address bar có icon "Install"
  • Click → install như Electron app

6.3 Lighthouse audit

Chrome DevTools → Lighthouse → PWA tab → run audit.

Tiêu chí pass:

  • Manifest có name, icons, theme_color
  • Service Worker registered
  • HTTPS
  • Responsive
  • Fast load (LCP <2.5s)
  • Works offline

Câu hỏi thường gặp

Q1: PWA install có thực sự thay native app?

  • Cho 80% use case: có
  • Cho game phức tạp, AR, deep iOS integration: không
  • iOS pre-17.4: hạn chế hơn (no push, no badge)
  • Android: tốt như native

Q2: PWA có lên Play Store / App Store được không?

Play Store: có, qua TWA (Trusted Web Activities) hoặc PWABuilder. Submit như app thường.

App Store: khó hơn. Phải wrap thành native app (Capacitor, Cordova). Apple strict với "wrapped web view".

Q3: SEO + PWA?

PWA vẫn là website → SEO tốt. SSR (Next.js, Remix) tốt nhất cho SEO + PWA combine.

Q4: Push notification iOS có work không?

Có từ iOS 16.4+ (March 2023). Trước đó chỉ Android + Desktop work.

Q5: Storage limit?

  • Chrome: 80% disk space
  • Safari: 1GB (5GB cho PWA installed)
  • Firefox: 50% disk space (max 2GB)

Nhiều cho most use case.

Tài liệu tham khảo


Phụ lục: PWA 2026

A. PWA Renaissance

  • iOS 17.4 EU DMA: install PWA như app native trong EU
  • WebAPK Android: Chrome tự tạo APK shell
  • Push notification iOS: work từ 16.4+
  • WebAssembly + WebGPU: heavy compute trong browser
  • Origin Trial: feature mới Web (File System Access, USB, BLE...)

B. PWA vs Mini App ecosystem VN

PWAZalo Mini App
AudienceMọi web userZalo user (75M VN)
InstallBrowser promptZalo native
PaymentStripe, VNPay tự tích hợpZaloPay built-in
DiscoverabilityGoogle SEOZalo search + share
UpdateReal-timeReview 1-3 ngày
OfflineYes (Service Worker)Limited
HardwareLimitedMore (camera, location)
Best forInternational, SEO-drivenVN mass market

C. Stack đề xuất 2026

Framework: Next.js 14+ (SSR + SEO) hoặc Vite + React
PWA: vite-plugin-pwa hoặc next-pwa
UI: Tailwind + shadcn/ui
State: Zustand + TanStack Query
Offline: IndexedDB (idb library) + Background Sync
Push: web-push + VAPID keys
Auth: Supabase Auth hoặc Clerk
Backend: Supabase / Cloudflare D1 + Workers
Deploy: Vercel / Cloudflare Pages

D. Khi nào KHÔNG dùng PWA

  • Game 3D heavy (Unity, Unreal)
  • App cần deep iOS integration (HealthKit, ARKit)
  • App phụ thuộc Bluetooth Low Energy (Web Bluetooth limited)
  • App cần CPU-intensive (video editing, ML training)
  • App cho enterprise iOS với MDM requirement

E. Use case PWA cho VN 2026

App ideaStack
News reader VNNext.js + Supabase + Vercel PWA
Habit trackerVite + IndexedDB + Background Sync
Recipe bookNext.js + Algolia search + image cache
Calculator/ToolVanilla JS PWA + offline-first
E-commerce liteNext.js Commerce + Stripe + PWA
Personal CRMVite + IndexedDB + sync với cloud
Voice memoWeb Audio API + IndexedDB + Whisper API
Photo galleryWeb Image API + IndexedDB

Sources