What is a PWA?
A Progressive Web App (PWA) is a web application that uses modern browser APIs to provide an experience similar to a native mobile app.
Main Characteristics of a PWA:
- Progressive – Works on any browser.
- Responsive – Fits desktop, mobile, or tablet.
- Offline-ready – Can work on low-quality networks or without connection.
- App-like – Feels like a native app (navigation, interactions).
- Fresh – Always up-to-date with service workers.
- Safe – Served via HTTPS.
- Installable – Users can add to their home screen.
- Discoverable – Search engines can find them (via manifest).
- Re-engageable – Can send push notifications.
- Linkable – Shared via simple URLs (no store required).
Build a Simple .NET API
Step 1: Create Project
dotnet new web -o PwaApi
Step 2: Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using System.Collections.Generic;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var posts = new List<Post>
{
new Post { Id = 1, Slug = "hello-world", Title = "Hello World", Content = "This is the first post." },
new Post { Id = 2, Slug = "dotnet-pwa", Title = ".NET PWA", Content = "How to build a PWA with .NET." }
};
app.MapGet("/api/posts", () => posts.Select(p => new { p.Slug, p.Title }));
app.MapGet("/api/posts/{slug}", (string slug) =>
{
var post = posts.FirstOrDefault(p => p.Slug == slug);
return post is not null ? Results.Ok(post) : Results.NotFound();
});
app.Run();
public class Post
{
public int Id { get; set; }
public string Slug { get; set; }
public string Title { get; set; }
public string Content { get; set; }
}
Run it: dotnet run
Simple PWA Frontend
index.html – Post List
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Blog Posts</title>
</head>
<body>
<h1>Blog Posts</h1>
<ul id="posts"></ul>
<script>
async function fetchPosts() {
const res = await fetch('/api/posts');
const posts = await res.json();
const ul = document.getElementById('posts');
ul.innerHTML = '';
posts.forEach(p => {
const li = document.createElement('li');
li.innerHTML = `<a href="post.html?slug=${p.slug}">${p.title}</a>`;
ul.appendChild(li);
});
}
fetchPosts();
// Register Service Worker
window.addEventListener('load', function () {
if ('serviceWorker' in navigator)
{
navigator.serviceWorker.register('/sw.js')
.then(() => console.log('Service Worker registered'))
.catch(err => console.error('SW registration failed', err));
////As soon as the browser has determined that it can install the app, it fires the beforeinstallprompt event in the global Window scope.
window.addEventListener('beforeinstallprompt', async (event)=> {
const relatedApps = await navigator.getInstalledRelatedApps();
});
}
else {
console.log('serviceWorker not in navigator');
}
});
</script>
</body>
</html>
post.html – Single Post
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Post</title>
</head>
<body>
<a href="index.html">← Back</a>
<h1 id="title">Loading...</h1>
<div id="content"></div>
<script>
const slug = new URLSearchParams(location.search).get('slug');
async function fetchPost() {
const res = await fetch(`/api/posts/${slug}`);
if (res.ok) {
const post = await res.json();
document.getElementById('title').textContent = post.title;
document.getElementById('content').textContent = post.content;
} else {
document.getElementById('title').textContent = 'Post not found';
}
}
fetchPost();
</script>
</body>
</html>
Add a Web App Manifest (manifest.json)
The manifest.json file is a simple JSON configuration that tells the browser how your web app should behave when installed on a device.
It defines the app’s name, short name, icons, start page, display mode (such as fullscreen or standalone), and theme colors.
This file is what makes a web app installable and appear like a native mobile app on a user’s home screen. Without it, the browser wouldn’t know how to represent your PWA outside of the browser tab.
Create the Service Worker (sw.js)
The sw.js file is a special JavaScript file that runs in the background, separate from your web page.
It acts as a network proxy that can intercept requests, cache files, and serve them even when the user is offline.
The service worker is responsible for improving performance, enabling offline access, and handling background tasks such as push notifications.
It follows a lifecycle with events like install, activate, and fetch, which allow developers to control how the app caches and updates its content.
sw.js
const STATIC_CACHE = 'static-cache-v1';
const DYNAMIC_POSTS_CACHE = 'posts-cache-v1';
const MAX_AGE = 3 * 24 * 60 * 60 * 1000; // Caches posts for 3 days, cleans old caches.
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE).then(cache =>
cache.addAll(['/','/index.html','/post.html'])
)
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== STATIC_CACHE && k !== DYNAMIC_POSTS_CACHE)
.map(k => caches.delete(k)))
)
);
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/posts/')) {
event.respondWith(cacheWithExpiration(event.request, DYNAMIC_POSTS_CACHE));
} else if (url.pathname === '/api/posts') {
event.respondWith(cacheFirst(event.request, DYNAMIC_POSTS_CACHE));
} else {
event.respondWith(caches.match(event.request).then(c => c || fetch(event.request)));
}
});
async function cacheWithExpiration(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
const date = cached.headers.get('sw-fetched-time');
if (date && Date.now() - parseInt(date, 10) < MAX_AGE) return cached;
}
return fetchAndCache(request, cache);
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
return fetchAndCache(request, cache);
}
async function fetchAndCache(request, cache) {
const response = await fetch(request);
if (response && response.status === 200) {
const headers = new Headers(response.headers);
headers.append('sw-fetched-time', Date.now().toString());
const data = await response.clone().arrayBuffer();
const customResponse = new Response(data, { status: response.status, headers });
cache.put(request, customResponse.clone());
return customResponse;
}
return response;
}
Testing Your PWA
- Use Chrome DevTools → Lighthouse to audit for PWA compliance.
- Check Application → Cache Storage to inspect caches.
- Run over HTTPS (or http://localhost during dev).
Production Tips
- Use IndexedDB for reliable cache metadata (instead of custom headers).
- Use Workbox (Google’s library) for easier caching strategies.
- Add loading states (show cached data first, then refresh with network).
- Configure .NET API to send Cache-Control headers for better alignment.
More Detail About Service Worker Lifecycle Recap
- Register (your script calls navigator.serviceWorker.register).
- Install (cache files, set up environment).
- Activate (clear old caches, take control).
- Idle (waits, intercepts fetch, messages, push, sync, etc.).
main events in sw.js and when they fire:
🔹 1. install
When it fires:
- The very first time the browser sees a new service worker (or when the file changes).
What it’s for:
- Usually used to pre-cache important assets (HTML, CSS, JS, icons, etc.) so your app can work offline.
self.addEventListener('install', event => { console.log('Service Worker installing...'); event.waitUntil( caches.open('app-cache-v1').then(cache => { return cache.addAll(['/css/site.css', '/js/site.js', '/offline.html']); }) ); });
🔹 2. activate
When it fires:
- After install, once the old service worker (if any) is no longer controlling clients.
- It’s the “activation” of the new worker.
What it’s for:
- Cleaning up old caches.
- Taking control of open pages immediately (clients.claim()).
self.addEventListener('activate', event => { console.log('Service Worker activating...'); event.waitUntil( caches.keys().then(keys => Promise.all(keys.map(key => { if (key !== 'app-cache-v1') return caches.delete(key); })) ) ); });
🔹 3. fetch
When it fires:
- Every time the app (pages it controls) makes an HTTP request (images, CSS, JS, API calls, etc.).
What it’s for:
- Intercepting network requests.
- Returning cached responses or fetching from the network.
- Building offline/fast-loading strategies.
self.addEventListener('fetch', event => { console.log('Fetching:', event.request.url); event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request); }) ); });
🔹 4. message
When it fires:
- When your web page (client) sends a message to the service worker using postMessage.
What it’s for:
- Communication between your app and the service worker.
self.addEventListener('message', event => { console.log('Message from client:', event.data); });
🔹 5. push
When it fires:
- When the browser receives a push notification from a server (requires Push API + Notification API).
What it’s for:
- Handling background push messages.
self.addEventListener('push', event => { const data = event.data.text(); event.waitUntil( self.registration.showNotification('New Message', { body: data }) ); });
🔹 6. sync (Background Sync)
When it fires:
- When the browser regains network connectivity after being offline.
What it’s for:
- Retrying failed requests (like sending forms, saving posts, etc.).
self.addEventListener('sync', event => { if (event.tag === 'sync-posts') { event.waitUntil(sendQueuedPosts()); } });