Think of the Service Worker as a proxy between your application and the network: it intercepts every request and decides whether to serve it from the cache or contact the server. This is precisely what makes offline mode possible.
If you have already read the complete guide to PWA, you know that the Service Worker is one of the three pillars of a Progressive Web App, alongside the Web App Manifest and HTTPS. But what happens inside—how it registers, intercepts requests, manages the cache, and survives updates—is something most tutorials skip.
In this article, we provide a technical breakdown from registration to caching strategies. Complete with code, common pitfalls, and explanations of why certain things work the way they do.
📚 Table of Contents
What is a Service Worker—and how it differs from regular JS
Regular JavaScript is code that lives alongside the page. It manipulates the DOM, reacts to clicks, and sends requests. When the page is closed, it disappears with it.
A Service Worker is different. It runs in a separate thread (worker thread), completely isolated from the page's main thread. This leads to three important consequences:
- No DOM access. A Service Worker cannot read or modify the page's HTML elements. It lives outside of it.
- Can live after the page is closed. The browser can "wake up" the Service Worker for background tasks—such as data synchronization or receiving push notifications—even when no tab of the site is open.
- Communicates via postMessage. If a Service Worker needs to pass something to the page or vice versa, they exchange messages via
postMessage(), like two separate processes.
Main thread vs Worker thread — what is the difference
The Main thread is the single thread where all the page's JavaScript is executed, rendering happens, and events are processed. If you run something heavy in the main thread, the page will "freeze."
The Worker thread is a separate thread that does not block the UI. A Service Worker is a specialized type of Web Worker designed for network requests and caching. Unlike a regular Web Worker, it has access to the Cache API and PushManager and can live independently of the page.
An important detail: A Service Worker is event-driven. It does not run constantly—it "wakes up" in response to specific events (fetch, push, sync) and "falls asleep" after processing them. This is the browser's solution for saving resources.
Where the Service Worker "lives" physically
A Service Worker is a separate JavaScript file, usually sw.js, located in the site's root directory or in a folder with a specific scope. The browser registers, downloads, and executes it independently of the page's main code.
The architecture looks like this:
[Page / Main App]
↕ (postMessage)
[Service Worker]
↕ ↕
[Cache API] [Network / Server]
When a page makes a request (for example, loading CSS or calling an API), the browser first "asks" the Service Worker what to do with that request. The Service Worker decides: return it from the cache, go to the network, or do something combined.
Lifecycle — Register → Install → Activate → Waiting
The Service Worker lifecycle is the most confusing topic for those starting out. Let’s break down each stage and the typical mistakes at each one.
Stage 1: Register
Registration occurs in the page code. You tell the browser: "there is this sw.js file, register it as a Service Worker for this origin."
If the browser sees this SW for the first time or the file has changed, the installation process begins. If the file hasn't changed (even by a single byte), the browser does nothing and uses the already active SW.
Typical mistake: registering the SW behind a condition or in a try/catch block without error handling. If the registration fails silently, you will never know.
Stage 2: Install
After registration, the browser downloads the SW file and triggers the install event. This is where critical resources—HTML shell, CSS, JS, icons—are usually cached.
Typical mistake: not waiting for caching to complete via event.waitUntil(). If you don't pass a promise to waitUntil, the browser won't know when the install has finished and might move to activation before the cache is filled.
Stage 3: Waiting (the most popular trap)
After a successful install, the new Service Worker does not become active immediately. It stays in a waiting state until the old SW releases all open tabs of the site.
Why is this? The browser protects session stability. If a new SW activated while an old tab was open, the caching strategy could change in the middle of a user's session. This could break the application.
In practice, this means: you deployed an update, it was cached—but users keeping the tab open continue to see the old version. On mobile, where tabs aren't closed for years, this situation can last a very long time.
The solution — skipWaiting():
self.addEventListener('install', event => {
self.skipWaiting(); // force activation, do not wait
event.waitUntil(
caches.open('my-app-v2').then(cache => {
return cache.addAll(['/index.html', '/app.css', '/app.js']);
})
);
});
Also, add clients.claim() in the activate event—so that already open tabs immediately come under the control of the new SW:
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
Stage 4: Active
When the old SW has released all tabs (or you forced skipWaiting()), the new SW becomes active. From this moment on, it intercepts all fetch requests from the page.
In the activate event, it is common practice to clean up old cache versions—those left over from previous SWs:
const CACHE_NAME = 'my-app-v2';
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME) // delete everything except the current version
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
Important: version your cache name with every deployment. If you keep the same name, old content will remain in the cache after an update. More on this trap in the article 8 critical mistakes when integrating PWA.
Service Worker Registration — the first working code
Registration occurs once in the page's main JavaScript code. Here is a minimal working example:
// main.js or inline in HTML
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js') // path to the SW file
.then(registration => {
console.log('SW registered, scope:', registration.scope);
})
.catch(error => {
console.error('SW registration error:', error);
});
});
}
Let's break down each part:
'serviceWorker' in navigator — checking browser support. If the browser doesn't support SW, the code simply won't run, and the site will continue working without offline capabilities.window.addEventListener('load', ...) — we wait for the page to load fully before registration. This prevents resource competition between the initial load and the SW..register('/sw.js') — path to the SW file. Note: the path starts from the root. The SW scope by default is the directory where the sw.js file is located.
What is scope and why it matters
Scope determines which URL requests the Service Worker will intercept. By default, it is the directory of the SW file.
// sw.js is in the root — intercepts ALL site requests
navigator.serviceWorker.register('/sw.js');
// scope = '[https://example.com/](https://example.com/)'
// sw.js is in /app/ — intercepts only /app/*
navigator.serviceWorker.register('/app/sw.js');
// scope = '[https://example.com/app/](https://example.com/app/)'
// scope can be explicitly specified
navigator.serviceWorker.register('/sw.js', { scope: '/shop/' });
// intercepts only /shop/*
Incorrect scope is one reason why an SW "fails to intercept" requests. If the SW file is located in /js/sw.js, it will by default only intercept /js/*, and not the entire site.
HTTPS — a mandatory requirement
A Service Worker only works via HTTPS. This is not a recommendation—it is a technical browser restriction. Without HTTPS, navigator.serviceWorker.register() simply will not work.
The only exception is localhost for development. HTTPS is not required on a local server.
If your CDN or subdomain for static files operates over HTTP, the SW won't be able to cache resources from it. This problem is discussed in detail in the article on 8 common PWA mistakes.
Cache API and Fetch event — how SW intercepts requests
Why not localStorage
The first question that arises: why does an SW use the Cache API for caching instead of the familiar localStorage?
The answer is simple: localStorage is synchronous and only available in the main thread. A Service Worker lives in a separate thread and does not have access to it. The Cache API is an asynchronous, Promise-based interface designed specifically for the worker context.
The Cache API is also significantly more spacious (hundreds of megabytes vs. 5–10 MB for localStorage), supports storing full HTTP responses with headers, and is well-suited for file caching.
How the Cache API works
// Open (or create) a cache with a name
const cache = await caches.open('my-app-v2');
// Add files to the cache
await cache.addAll([
'/',
'/index.html',
'/app.css',
'/app.js',
'/icons/icon-192.png'
]);
// Or add a single file
await cache.add('/fonts/main.woff2');
// Retrieve a cached resource
const response = await caches.match('/app.css');
// Delete a cache
await caches.delete('my-app-v1');
The key in the Cache API is the request URL. Each resource is identified by its address. Therefore, if the URL changes, it is considered a new resource.
Fetch event — the heart of the Service Worker
When any page within the SW scope makes a network request, the browser generates a fetch event in the Service Worker. This is where all the logic happens: take from cache, go to the network, or a combination.
self.addEventListener('fetch', event => {
// event.request — request object (URL, method, headers)
// event.respondWith() — intercept the request and return a response
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
return cached; // found in cache — return immediately
}
return fetch(event.request); // not found — go to network
})
);
});
event.respondWith() is the key method. It tells the browser: "don't make a standard network request, I will return the response myself." If you don't call respondWith(), the browser will go to the network as usual.
Important: respondWith() must be called synchronously in the event handler, but it can accept a promise that resolves later.
Caching Strategies — which one to choose for your project
There is no single correct caching strategy. The choice depends on the type of content and what is more important: data freshness or speed and offline access. Here are the four main strategies with code:
| Strategy | When to use | Data Freshness |
|---|
| Cache First | Static assets (CSS, fonts, icons) | Low — always from cache |
| Network First | API, dynamic content, HTML pages | High — network first |
| Stale While Revalidate | News, blogs, avatars | Medium — from cache + background update |
| Cache Only | Offline fallback, pre-cached resources | Cache only, network not used |
Cache First
Check the cache first; only if it's not there, go to the network. Ideal for resources that rarely change.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
// Store the new response in the cache
return caches.open('static-v2').then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
Network First
Go to the network first; if unavailable, return from cache. Ideal for HTML pages and APIs where freshness is critical.
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
const clone = response.clone();
caches.open('dynamic-v2').then(cache => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(() => caches.match(event.request)) // offline — from cache
);
});
Stale While Revalidate
Return from cache immediately (fast!) and update the cache in the background in parallel. The next request will receive the fresh version. Good for content where a slight update delay is acceptable.
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('content-v2').then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
cache.put(event.request, response.clone()); // background update
return response;
});
return cached || fetchPromise; // return cache or wait for network
});
})
);
});
Cache Only
Cache only; the network is not used. The resource must be cached beforehand (e.g., during install). Used for offline fallback pages.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
);
});
How to combine strategies in a real project
In practice, different types of resources require different strategies. In the Kazki AI project, the following approach is used:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Do not intercept: POST requests, API, authorization
if (event.request.method !== 'GET') return;
if (url.pathname.startsWith('/api/')) return;
if (url.pathname.startsWith('/login') || url.pathname.startsWith('/oauth2')) return;
// Static assets (CSS, JS, fonts, images) — Cache First
if (url.pathname.match(/.(css|js|woff2|png|jpg|svg)$/)) {
event.respondWith(
caches.match(event.request).then(cached =>
cached || fetch(event.request).then(response => {
caches.open('static-v2').then(c => c.put(event.request, response.clone()));
return response;
})
)
);
return;
}
// HTML pages — Network First
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
caches.open('pages-v2').then(c =>
c.put(event.request, response.clone())
);
}
return response;
})
.catch(() => caches.match(event.request) || caches.match('/offline.html'))
);
});
When a Service Worker causes harm—and how not to overdo it
A Service Worker is a powerful tool, but it can easily become a source of problems if used without limits. Here are three situations where an SW does more harm than good.
Problem 1: Caching HTML pages with a Cache First strategy
This is the most dangerous mistake. If HTML is cached with Cache First, users remain on the old version forever. They won't see new features, fixed bugs, or changed content until they manually clear the cache.
The rule is simple: HTML and API — always Network First. Static assets (CSS, JS with hashes in the name, fonts, images) — Cache First.
A detailed breakdown of this error with code can be found in the article 8 critical mistakes when integrating PWA.
Problem 2: Unlimited cache size
The browser provides limited space for the cache. If you cache everything, sooner or later the browser will start automatically deleting old entries, and you won't have control over what disappears.
Limit the cache size manually. Here is a simple approach — delete the oldest entries when the cache exceeds the limit:
const MAX_CACHE_SIZE = 50; // maximum 50 files in cache
async function trimCache(cacheName) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > MAX_CACHE_SIZE) {
// delete the oldest (first in the list)
await cache.delete(keys[0]);
await trimCache(cacheName); // recursive until within limit
}
}
// Call after every addition to the cache
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
caches.open('dynamic-v2').then(async cache => {
await cache.put(event.request, response.clone());
await trimCache('dynamic-v2');
});
return response;
})
);
});
Problem 3: Caching authorized pages and personal data
Never cache pages that contain personal user data, authorized API responses, or payment forms. If you cache a profile page, there is a risk of showing one user's data to another (for example, on a shared device).
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// These routes are completely excluded from caching
const skipPaths = [
'/api/', '/login', '/logout', '/register',
'/dashboard', '/profile', '/payment', '/admin'
];
if (skipPaths.some(path => url.pathname.startsWith(path))) {
return; // do not call respondWith — browser goes to network directly
}
// Handle the rest
event.respondWith(/* ... */);
});
Offline fallback and Background Sync
Offline fallback — /offline.html instead of the Chrome dinosaur
When a user is offline and requests a page that isn't in the cache, the browser displays a standard error or the famous Chrome dinosaur by default. This is poor UX—the user doesn't understand what is happening.
The correct solution is to show your own /offline.html page with a message and perhaps a "Try Again" button.
First, cache offline.html during install:
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html', // mandatory
'/app.css',
'/app.js'
];
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
caches.open('static-v2').then(cache => cache.addAll(STATIC_ASSETS))
);
});
Then return it as a fallback when the network is unavailable:
self.addEventListener('fetch', event => {
// Only for navigation requests (pages, not resources)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match('/offline.html');
})
);
return;
}
// For other resources — standard logic
});
This way, the user sees your branded page with an explanation instead of a standard browser error.
Background Sync — what happens to offline actions
The Background Sync API solves a major problem: what to do if a user submits a form or a comment, and the connection drops at that exact moment?
Without Background Sync, the data is simply lost. With it, the request is queued and automatically sent when the connection is restored, even if the user has already closed the tab.
// On the page — register a sync tag when attempting to submit
async function submitForm(data) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
} catch (error) {
// Offline — save data and register sync
await saveToIndexedDB('pending-submissions', data);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
}
}
// In the Service Worker — handle when the connection is restored
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(
getFromIndexedDB('pending-submissions').then(async items => {
for (const item of items) {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(item)
});
}
await clearIndexedDB('pending-submissions');
})
);
}
});
Browser support: Background Sync is supported in Chrome and Edge. Support for iOS Safari appeared in Safari 18+ and is currently unstable. If reliability is critical, use it as a progressive enhancement on top of a standard retry mechanism.
Debugging in DevTools — how to check if everything is working
A Service Worker is difficult to debug with standard tools because it lives outside the page. However, Chrome DevTools provides everything you need.
Application tab → Service Workers
Open DevTools (F12) → Application tab → Service Workers section.
Here you can see:
- Status — active SW (green dot), waiting (yellow), or stopped.
- Source — link to the sw.js file. Clicking it opens it in the debugger.
- Update on reload — when enabled, the browser always checks for a new SW version on every reload. Useful during development.
- Bypass for network — the SW is temporarily disabled, and all requests go directly to the network. Convenient for checking how the site looks without the cache.
- skipWaiting — a button to force the activation of a new SW stuck in the Waiting state.
Checking the cache
Application → Cache Storage. Here you can see all named caches, the list of cached URLs, and their sizes. You can click on any entry to view the response headers.
Simulating offline mode
Application → Service Workers → check Offline. Alternatively, in the Network tab → select No throttling → Offline.
After this, reload the page—you will see exactly what your SW shows in offline mode. Does /offline.html appear? Are cached resources loading?
Forcing SW updates during development
To avoid closing tabs after every sw.js change, enable Update on reload in Application → Service Workers. This forces the browser to treat the SW as changed on every reload.
Or you can fully reset the state: Application → Storage → Clear site data. This deletes all caches, IndexedDB, cookies, and unregisters the SW. Useful when something is "stuck" and you need a clean start.
Logging in the SW
Service Worker logs do not appear in the standard page console. To see them: Application → Service Workers → click inspect next to the active SW. A separate DevTools window with the SW console will open.
// In sw.js — log for debugging
self.addEventListener('fetch', event => {
console.log('[SW] Intercepted request:', event.request.url);
// ...
});
Once the Service Worker is configured, the logical next step is push notifications. A Service Worker can receive them from the server even when the browser is closed. For a detailed breakdown with code for iOS, see the article PWA Push Notifications on iOS in 2026: What actually works.
❓ Frequently Asked Questions (FAQ)
Can a Service Worker intercept requests from other sites?
No. A Service Worker is restricted to its own origin (protocol + domain + port) and scope. It cannot intercept requests to other domains or modify requests to external APIs—only resources belonging to its domain within the specified scope.
What happens if there is a syntax error in sw.js?
The Service Worker will fail to register. The browser will try to execute the file, encounter an error, and reject the registration. An already active SW will continue to run—the new one with the error will not replace it. That's why it's crucial to always handle .catch() after register() and check the DevTools console during development.
How many Service Workers can one site have?
Technically several, if they have different scopes. For example, /sw-shop.js for /shop/* and /sw-blog.js for /blog/*. In practice, a single SW at the root with conditional logic inside is easier to maintain.
Is a Service Worker needed for a regular site without PWA?
Not strictly necessary, but it can be beneficial. Even without offline mode, an SW provides faster repeat loads through static caching and the ability to show an offline page instead of a browser error. If the site is public and speed is important, an SW is worth considering even without a full PWA.
Why isn't the Service Worker updating even though I changed sw.js?
Most likely, the new SW is stuck in the Waiting state—the old version is still active. Check Application → Service Workers in DevTools: if you see a yellow dot and "waiting" status, click skipWaiting or close all tabs of the site. In production, this is solved via self.skipWaiting() in the install event—as shown in the Lifecycle section.
Does a Service Worker affect SEO?
It can—both positively and negatively. Positively: faster loading improves Core Web Vitals. Negatively: if HTML is cached with Cache First, Googlebot may receive outdated content. Rule: always use Network First for HTML pages so the crawler sees the most recent version. For more on PWA's impact on SEO, see the complete guide to PWA.
✅ Conclusions
A Service Worker is not magic or a complex technology once you understand its components. To summarize the key points:
- SW lives in a separate thread — no DOM access, but it has the Cache API and PushManager. It communicates with the page via postMessage.
- Lifecycle has three stages — Register → Install → Activate. The Waiting trap is solved via
skipWaiting() + clients.claim(). - HTTPS is mandatory — without it, the SW simply won't register.
- Caching strategy depends on content type — Cache First for statics, Network First for HTML and APIs, Stale While Revalidate for content pages.
- Don't cache everything — exclusion for authorization, personal data, and API responses. Limit the cache size manually.
- Offline fallback — always prepare your own
/offline.html instead of the standard browser error. - DevTools is your primary tool for checking SW status, cache, and simulating offline mode.
If you are still deciding between a PWA and a native app, read the detailed comparison with real figures and the Kazki AI case study in the article PWA vs Native App in 2026.
The next step after setting up the Service Worker is push notifications. How to implement this on iOS given all Safari restrictions is covered in the article PWA Push Notifications on iOS in 2026: What actually works.
📖 Sources and Useful Links
Ключові слова:
service workerspring boot offlinepwa блогLRU cache Slug: offline-blog-service-worker-implementation