Service Worker in TypeScript
This article explains Service Workers in TypeScript.
We will explain Service Workers in TypeScript, including practical examples.
YouTube Video
Service Worker in TypeScript
A Service Worker is a “request proxy” that sits between the browser and the network. It enables fetch interception, cache control, offline support, and background processing (sync and push). Using TypeScript provides type safety and increases maintainability.
Setting Up TypeScript
tsconfig.json
(Enable WebWorker types)
Let's look at an example of enabling the WebWorker type in tsconfig.json
.
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["sw.ts"]
13}
- By adding
WebWorker
to thelib
array, you can use types likeServiceWorkerGlobalScope
. DOM
andWebWorker
have different types, so it is common practice to separate thetsconfig.json
settings for the browser (main app) andService Worker
.Service Worker
files are ultimately output to a path that matches the scope (usually the site root/sw.js
).- For security reasons, Service Workers only run over HTTPS (or on
localhost
).
Registration Code on the Browser Side
register-sw.ts
1// register-sw.ts
2async function registerServiceWorker() {
3 if (!('serviceWorker' in navigator)) return;
4
5 try {
6 const registration = await navigator.serviceWorker.register(
7 '/sw.js', { scope: '/' }
8 );
- This process registers a
Service Worker
.scope
refers to the range of paths that theService Worker
can control. For example, if you place/sw.js
directly under the root and set thescope
to the root directory (/
), you can control all the resources of the entire site. On the other hand, if you specify a particular directory such as/app/
, only the contents under that directory will be controlled.
1 // If there's a waiting worker, notify the user.
2 if (registration.waiting) {
3 promptUserToUpdate(registration);
4 }
waiting
indicates the state in which a new Service Worker has been installed and is waiting to be activated. At this stage, existing pages are still controlled by the oldService Worker
, so it is common to prompt the user for confirmation, and after getting approval, callskipWaiting()
to immediately activate the newService Worker
. This allows you to reflect the latest processes without waiting for the next page reload.
1 // When a new SW is installing, monitor its state changes
2 registration.addEventListener('updatefound', () => {
3 const newWorker = registration.installing;
4 if (!newWorker) return;
5 newWorker.addEventListener('statechange', () => {
6 if (newWorker.state === 'installed' &&
7 navigator.serviceWorker.controller) {
8 // New content available, prompt the user
9 promptUserToUpdate(registration);
10 }
11 });
12 });
updatefound
is triggered when the installation of a new Service Worker has started. When this event occurs, a new worker is set inregistration.installing
, so by monitoring itsstatechange
, you can detect when the installation has been completed (installed
). Furthermore, ifnavigator.serviceWorker.controller
exists, it means that an old Service Worker is already controlling the page, so this is an opportunity to notify the user about the existence of a new version.
1 // When the active worker changes (e.g., after skipWaiting), reload if desired
2 navigator.serviceWorker.addEventListener('controllerchange', () => {
3 // Optionally reload to let the new SW take over
4 window.location.reload();
5 });
6 } catch (err) {
7 console.error('Service Worker registration failed: ', err);
8 }
9}
- The
controllerchange
event fires at the moment when the new Service Worker starts controlling the current page. Reloading at this point will immediately apply new cache strategies and processing. However, automatic reloading can degrade user experience, so it is preferable to reload after obtaining user consent.
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2 // Show UI to user. If user accepts:
3 if (reg.waiting) {
4 reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5 }
6}
7
8registerServiceWorker();
- By having the
Service Worker
receive apostMessage({ type: 'SKIP_WAITING' })
from the client and then callingself.skipWaiting()
, you can prompt an update.
Scope Declaration in sw.ts
Next, let’s look at a typical Service Worker example that implements app shell caching.
When using Service Workers in TypeScript, it's useful to assign the correct type to self
.
1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
- In TypeScript,
self
is treated asany
by default, so without additional typing, you won't get type completion or type checking for Service Worker-specific APIs likeskipWaiting()
orclients
. - Specifying
ServiceWorkerGlobalScope
enables auto-completion, prevents misuse, and allows for safer development separated from regular DOM scripts.
Basic Service Worker (Install/Activate/Fetch)
It demonstrates simple cache version management, precaching on install, deleting old caches on activate, and cache strategies on fetch (cache-first for static assets, network-first for APIs).
sw.ts
(Minimal Setup + Cache Skeleton)
1const CACHE_NAME = 'app-shell-v1';
2const STATIC_ASSETS = [
3 '/',
4 '/index.html',
5 '/styles.css',
6 '/main.js',
7 '/fallback.png'
8];
9
10self.addEventListener('install', (event: ExtendableEvent) => {
11 // Pre-cache application shell
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then(cache => cache.addAll(STATIC_ASSETS))
15 // Activate immediately (optional: coordinate with client)
16 .then(() => self.skipWaiting())
17 );
18});
- During the
install
event, the app's static resources (App Shell) are pre-cached. By callingself.skipWaiting()
, the newService Worker
is activated immediately, making the latest cache available without waiting for the next access.
1self.addEventListener('activate', (event: ExtendableEvent) => {
2 // Clean up old caches and take control of clients immediately
3 event.waitUntil(
4 caches.keys().then(keys =>
5 Promise.all(keys
6 .filter(key => key !== CACHE_NAME)
7 .map(key => caches.delete(key)))
8 ).then(() => self.clients.claim())
9 );
10});
- In the
activate
event, old versions of caches are deleted, and theService Worker
is kept up to date. Furthermore, by callingself.clients.claim()
, the newService Worker
can control all clients without waiting for the page to be reloaded.
1self.addEventListener('fetch', (event: FetchEvent) => {
2 const request = event.request;
3 const url = new URL(request.url);
4
5 // Navigation requests (SPA) -> network-first with fallback to cached index.html
6 if (request.mode === 'navigate') {
7 event.respondWith(
8 fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
9 );
10 return;
11 }
12
13 // Simple API routing: network-first for /api/
14 if (url.pathname.startsWith('/api/')) {
15 event.respondWith(networkFirst(request));
16 return;
17 }
18
19 // Static assets: cache-first
20 event.respondWith(cacheFirst(request));
21});
- In
fetch
, you can intercept requests and control the response. You can implement strategies such as cache-first or network-first, which are useful for offline support and performance.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2 const data = (event as any).data;
3 if (!data) return;
4
5 if (data.type === 'SKIP_WAITING') {
6 // Force the waiting service worker to become active
7 self.skipWaiting();
8 }
9});
- If
SKIP_WAITING
is received, callingself.skipWaiting()
allows you to instantly activate the waiting Service Worker. As a result, the new version will be applied from the next request without the need to reload the page.
Practical Cache Strategy Overview
cache-first
Cache-first checks the cache first and returns the response immediately if available. If not, it fetches from the network and caches the result. This is suited for static files.
1async function cacheFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 const cached = await cache.match(request);
4 if (cached) {
5 return cached;
6 }
7 const response = await fetch(request);
8 if (response && response.ok) {
9 cache.put(request, response.clone());
10 }
11 return response;
12}
- This code demonstrates a cache-first implementation. If there is a cache, it returns it; if not, it fetches from the network and saves it to the cache. It's suitable for static resources that rarely change, such as images or CSS.
network-first
Network-first tries the network first and falls back to the cache upon failure. This is suitable for APIs where freshness is important.
1async function networkFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 try {
4 const response = await fetch(request);
5 if (response && response.ok) {
6 cache.put(request, response.clone());
7 }
8 return response;
9 } catch (err) {
10 const cached = await cache.match(request);
11 if (cached) return cached;
12 return new Response(JSON.stringify({ error: 'offline' }), {
13 status: 503,
14 headers: { 'Content-Type': 'application/json' }
15 });
16 }
17}
- This code demonstrates a network-first implementation. If a network response is received, it saves it in the cache; if it fails, it returns the cached version. It’s suitable for resources that require fresh data, like news articles or API responses.
stale-while-revalidate
stale-while-revalidate returns the cache first and simultaneously updates it from the network in the background. This balances response speed and freshness.
1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
2 const cache = await caches.open(cacheName);
3 const cachedResponse = await cache.match(request);
4 const networkFetch = fetch(request).then(networkResponse => {
5 if (networkResponse && networkResponse.ok) {
6 cache.put(request, networkResponse.clone());
7 }
8 return networkResponse;
9 }).catch(() => undefined);
10
11 // Return cached immediately if exists, otherwise wait network
12 return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
- This code returns the cache immediately if available, while fetching new data from the network in the background to update the cache. It provides fast responses to users and uses updated content for the next access, making it suitable for UI or lightweight data delivery.
Optimizing the Update Flow (Update Notification and Safe Reload)
Service Worker
updates are not immediate; the new version will remain waiting until existing tabs are closed.
Here, we implement a system to notify the client when the new version is ready and reload the page safely based on user action.
Notify the client from the Service Worker
side when the new version is ready.
1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
2async function notifyClientsUpdated() {
3 const all = await self.clients.matchAll({ type: 'window' });
4 for (const client of all) {
5 client.postMessage({ type: 'SW_UPDATED' });
6 }
7}
8
9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11 event.waitUntil((async () => {
12 if ('navigationPreload' in self.registration) {
13 await self.registration.navigationPreload.enable();
14 }
15 // cache cleanup
16 const cacheNames = await caches.keys();
17 await Promise.all(
18 cacheNames.map((name) => {
19 if (name !== CACHE_NAME) {
20 return caches.delete(name);
21 }
22 })
23 );
24
25 await self.clients.claim();
26 await notifyClientsUpdated();
27 })());
28});
- In this code,
notifyClientsUpdated
is called at the end of theactivate
event to notify all connected clients that the new version is ready.clients.claim()
is a method that immediately brings currently open pages (clients) under the control of the newly activated Service Worker. Normally, aService Worker
starts controlling the page only on the next load, but by usingclients.claim()
, you can bring the page under control immediately without reloading.
Display update UI on the client, and reload with user action
1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3 if (e.data?.type === 'SW_UPDATED') {
4 // Show a non-intrusive toast or banner: "New version available"
5 // When user clicks "Reload", call:
6 window.location.reload();
7 }
8});
- The client receives
SW_UPDATED
via themessage
event and displays an update notification in the UI. When the user chooses to reload,window.location.reload()
is executed, updating old HTML, CSS, and other resources on the page to the latest version. This ensures that the cache and control by theService Worker
switched withclients.claim()
are reflected throughout the entire page.
Offline Fallback
Prepare /offline.html
for critical navigation, and provide a minimal UI that conveys meaning even without images or fonts. If an API call fails, show the last cached state if possible and try to refetch in the background to improve UX.
Implementation Example
1// sw.ts
2const CACHE_NAME = 'app-cache-v1';
3
4// Cache offline.html during install
5self.addEventListener('install', (event) => {
6 event.waitUntil((async () => {
7 const cache = await caches.open(CACHE_NAME);
8 await cache.addAll(['/offline.html']);
9 })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14 const request = event.request;
15
16 // Navigation requests (e.g., page transitions)
17 if (request.mode === 'navigate') {
18 event.respondWith((async () => {
19 try {
20 // Try to fetch from the network as usual
21 return await fetch(request);
22 } catch (err) {
23 // On failure, return offline fallback page
24 const cache = await caches.open(CACHE_NAME);
25 return await cache.match('/offline.html') as Response;
26 }
27 })());
28 }
29});
- Precache
/offline.html
during theinstall
event so you can return at least a minimal page when the network is unavailable. - In the
fetch
event, you can monitor navigation requests withrequest.mode === 'navigate'
and specifically target page transitions. - Fallback to
/offline.html
when the network fails, ensuring it displays even when offline.
Messaging between the client and the Service Worker
Because the Service Worker
operates independently of the page lifecycle, bidirectional messaging is important for notifying states and executing commands. Specifying types for messages helps prevent incorrect message sending, enables code completion, and makes your implementation more robust.
Code Example
- Definition of Message Types
1type SwToClient =
2 | { type: 'SW_READY' }
3 | { type: 'SW_UPDATED' }
4 | { type: 'CACHE_CLEARED' }
5 | { type: 'PING'; ts: number };
6
7type ClientToSw =
8 | { type: 'CLEAR_CACHE' }
9 | { type: 'PING'; ts: number };
SwToClient
is the type of message sent from the Service Worker to the client.ClientToSw
is the type of message sent from the client to the Service Worker.- This allows you to clarify the types of events that can be exchanged through bidirectional communication.
- Processing on the Service Worker side
1self.addEventListener('message', (event) => {
2 const data = event.data as ClientToSw;
3 if (data?.type === 'CLEAR_CACHE') {
4 event.waitUntil((async () => {
5 const keys = await caches.keys();
6 await Promise.all(keys.map((k) => caches.delete(k)));
7 await broadcast({ type: 'CACHE_CLEARED' });
8 })());
9 } else if (data?.type === 'PING') {
10 event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11 }
12});
- The Service Worker receives messages from the client and branches processing based on type.
- For
CLEAR_CACHE
, it deletes the cache and then notifies all clients withCACHE_CLEARED
. - For
PING
, it replies to the original client with aPING
message including a timestamp.
- Notify All Clients from Service Worker
1async function broadcast(msg: SwToClient) {
2 const clients = await self.clients.matchAll({ includeUncontrolled: true });
3 for (const c of clients) c.postMessage(msg);
4}
- Use
clients.matchAll
to get all window tabs. - By sending
postMessage
to each, you can broadcast messages. - This can be used for update notifications (such as
SW_UPDATED
) and error notifications.
- Processing on the Client Side
1navigator.serviceWorker.controller?.postMessage({
2 type: 'PING',
3 ts: Date.now()
4} as ClientToSw);
- By sending a
PING
from the client and receiving a response from theService Worker
, you can verify that bidirectional communication is working properly. This makes it easier to test connection states and message handling.
1navigator.serviceWorker.addEventListener('message', (e) => {
2 const msg = e.data as SwToClient;
3 switch (msg.type) {
4 case 'SW_READY':
5 console.log('Service Worker is ready');
6 // Example: hide loading spinner or enable offline UI
7 break;
8 case 'SW_UPDATED':
9 console.log('A new version of the Service Worker is available');
10 // Example: show update notification or reload prompt
11 const shouldReload = confirm('A new version is available. Reload now?');
12 if (shouldReload) {
13 window.location.reload();
14 }
15 break;
16 case 'CACHE_CLEARED':
17 console.log('Cache cleared');
18 // Example: show confirmation message to user
19 alert('Cache has been successfully cleared.');
20 break;
21 case 'PING':
22 console.log(`Received PING response, ts=${msg.ts}`);
23 break;
24 }
25});
- {^ i18n_speak
クライアント側では
Service Worker
から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY
は初期化完了、SW_UPDATED
は新バージョン検出、CACHE_CLEARED
はキャッシュ削除完了、PING
は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}
Benefits of Typed Messaging
- Using typed messages makes which messages can be sent and received clear, and auto-completion and type checking improve safety.
postMessage
enables one-to-one communication andbroadcast
enables one-to-many communication.- You can easily implement essential features like update notifications (
SW_UPDATED
), cache management (CACHE_CLEARED
), and health checks (PING
).
Summary
- Using TypeScript adds type safety to Service Worker API calls and messaging, greatly improving development efficiency and maintainability.
- Understanding the
install
,activate
, andfetch
lifecycle events, and choosing the right caching strategy (such as cache-first or network-first) for each situation leads to a better user experience. - For operations, understanding cache version management and update flows (
updatefound
,waiting
,SKIP_WAITING
, etc.) is essential. - By adopting typed messaging for client-
Service Worker
communication, you can prevent misimplementation and establish a system that is easy to extend and maintain in the long term.
You can follow along with the above article using Visual Studio Code on our YouTube channel. Please also check out the YouTube channel.