⏱️ Lectura: 15 min
Turn off the wifi halfway through a session and some websites keep responding instantly: that behavior isn’t browser magic, it’s controlled by a Service Worker, a script that runs separately from the page and decides what to do with every network request.
📑 En este artículo
- TL;DR
- What a Service Worker Is and Why It Matters
- How It Works: The Full Lifecycle
- Practical Examples: From Zero to Cache-First
- Getting Started: From a Static Site to an Installable PWA
- Real Use Cases
- Common Mistakes and Best Practices
- Comparison: Which Caching Strategy to Use
- Going Deeper: Background Sync, Push, and Workbox
- Frequently Asked Questions
- References
It’s the piece that turns an ordinary website into a Progressive Web App (PWA): the one that lets you open a news app or the site you read yesterday and see content even when the subway tunnel cuts your signal. This guide explains the full lifecycle of a service worker, the caching strategies PWAs use in production, and the mistakes that break an implementation on the first deploy.
TL;DR
- You’ll understand the full Service Worker lifecycle: install, activate, and fetch.
- You’ll be able to register a Service Worker and cache assets with the Cache API in minutes.
- You’ll distinguish 5 caching strategies (cache-first, network-first, stale-while-revalidate, and more) and when to use each.
- You’ll know how to debug a Service Worker in Chrome DevTools and force its update without closing tabs.
- You’ll build a basic manifest.json so your website is installable as a PWA.
- You’ll learn the real gotchas: scope, mandatory HTTPS, and the zombie worker problem.
- You’ll compare Service Workers against AppCache (deprecated) and against Workbox as an abstraction layer.
What a Service Worker Is and Why It Matters
A service worker is a JavaScript script the browser runs on a separate thread, with no access to the DOM, window, or document. It registers once from your page and then stays installed in the user’s browser, active even with the tab closed.
Its main job is to act as a programmable network proxy: it intercepts every fetch the page makes and decides whether to respond from the Cache API, go to the network, or combine both. That capability is what makes a PWA’s offline mode possible, along with instant loading of repeated assets and push notifications.
Before the service worker, the only offline caching mechanism was AppCache, an API so hard to debug that it ended up deprecated. The Service Worker replaced it because it gives explicit, event-by-event control over what gets cached and when, instead of an all-or-nothing declarative list.
Running on a separate thread isn’t a minor detail: it means a service worker can stay alive processing a push notification or a background sync even after the user has closed every tab of the site. No other script on the page has that privilege.
How It Works: The Full Lifecycle
A service worker goes through well-defined states, and understanding those states is the difference between a PWA that updates cleanly and one that serves stale content for weeks.
Registration and Installation
It all starts when the page calls navigator.serviceWorker.register(). The browser downloads the file, compares it byte by byte against the previous version (if one exists), and if it changed, fires the install event. That’s the typical moment to precache the app shell: the minimal HTML, CSS, and JS needed for the interface to load on its own, without depending on the network.
Activation and Control
After installing, the worker stays in the waiting state until no tab is using the previous version. activate is the right place to delete stale caches from previous versions. Only then does the worker start controlling the open pages’ network requests.
flowchart TD
A["Registration: navigator.serviceWorker.register()"] --> B["Installing"]
B --> C["Installed, waiting"]
C --> D["Activating"]
D --> E["Activated"]
E --> F["Controls the page and responds to fetch"]
F --> G["Redundant"]
The next diagram shows what happens each time the page requests a resource once the service worker is already active and controlling the tab:
sequenceDiagram
participant N as Browser
participant SW as Service Worker
participant C as Cache Storage
participant R as Network
N->>SW: requests index.html
SW->>C: checks cache
alt Resource in cache
C-->>SW: returns resource
SW-->>N: responds from cache
else Not in cache
SW->>R: requests resource from network
R-->>SW: returns data
SW->>C: stores copy in cache
SW-->>N: responds with fresh data
end
Practical Examples: From Zero to Cache-First
Let’s build a real service worker in three steps, each one adding a layer of behavior on top of the previous one.
Step 1: Register the Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
console.log('Service Worker registered with scope:', reg.scope);
});
}
This block goes in your main HTML or startup bundle. If the browser doesn’t support serviceWorker, the if prevents the error and the site keeps working normally, just without offline capabilities.
Step 2: Cache the App Shell on Install
const CACHE_NAME = 'programacion-app-v1';
const APP_SHELL = ['/', '/index.html', '/styles.css', '/app.js'];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(APP_SHELL);
})
);
self.skipWaiting();
});
When this worker installs, it downloads and caches the four listed files. event.waitUntil() tells the browser not to mark the installation complete until cache.addAll() finishes; if a single file in the list fails, the whole installation fails.
Step 3: Respond from Cache with Fetch
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(cached) {
return cached || fetch(event.request).then(function(response) {
return caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
With this event, every request first looks for a match in cache. If it exists, it responds instantly without touching the network (the cache-first strategy). If it doesn’t exist, it requests the resource from the network, stores it for next time, and only then responds to the page.
Bonus: Stale-While-Revalidate
function staleWhileRevalidate(request) {
return caches.open(CACHE_NAME).then(function(cache) {
return cache.match(request).then(function(cached) {
const fetchPromise = fetch(request).then(function(response) {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
});
});
}
This variant responds immediately with whatever is in cache, even if it’s outdated, and in parallel requests the new version from the network for the next visit. It’s the balance between perceived speed and fresh data, and it’s the strategy Workbox uses by default for CSS and JS.
Getting Started: From a Static Site to an Installable PWA
For the browser to offer installing your site as an app, you need three pieces: HTTPS, a registered service worker, and a valid manifest.json linked with <link rel='manifest' href='/manifest.json'> in the head.
{
"name": "My Programming PWA",
"short_name": "MyPWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0b5fff",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
The display: standalone field is what makes the app open without the browser’s address bar, like a native app. The 192×192 and 512×512 icons are the minimum Chrome requires to show the install prompt.
To test it locally without buying a certificate: first, serve the site with a server that supports local HTTPS, or simply use localhost, which the browser exempts from the HTTPS requirement. Second, open Chrome DevTools, the Application tab, Service Workers section, and confirm it shows as activated and is running. Third, run a Lighthouse audit, built into DevTools, and pick the Progressive Web App category to see what’s missing.
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(reg) {
console.log(reg.scope, reg.active ? reg.active.state : 'no active worker');
});
});
This snippet, run from the browser console, lists every registered service worker, its scope, and its state. If reg.active is null, the worker is still installing or waiting, and isn’t serving anything yet.
💡 Tip: callself.skipWaiting()in the install event andself.clients.claim()in activate if you want the new service worker to take control immediately, without waiting for the user to close every tab.
Real Use Cases
The pattern repeats across different industries, always with the same logic: respond fast from cache and update in the background.
- News sites that precache the app shell so the user sees the header and navigation instantly, even when the phone’s connection is failing.
- E-commerce catalogs that cache product images already viewed, so navigating back in the browser doesn’t download anything again.
- Offline-first technical documentation, where the developer wants to keep reading an API reference on a flight or in a tunnel.
- Lightweight map apps that cache tiles from areas already visited, so the map doesn’t go blank when signal is lost.
- Push notifications: a service worker can receive a message from the server and show an operating system notification even with the tab closed, using the Push API.
Common Mistakes and Best Practices
⚠️ Watch out: service workers only run over HTTPS, with the exception of localhost for development. If your site serves over plain HTTP in production, the browser ignores the registration without throwing a visible console error.
📌 Note: a service worker’s scope is defined by the folder where the .js file lives. If you register/sw.jsfrom the root, it controls the entire site; if it lives at/app/sw.js, it only controls/app/*.
The most common production mistake is publishing an update and having users keep seeing the old version for days. It happens because the new worker stays in waiting until every tab closes, and most people never fully close their browser. The partial solution is skipWaiting() plus clients.claim(), though that has its own cost: a tab can end up mid-session with old JS while a new service worker serves different assets.
Another classic mistake is not versioning the cache name. If CACHE_NAME stays fixed as app-cache forever, you’ll never be able to invalidate old assets without manually clearing the entire Cache Storage. The standard practice is to suffix it with a build number or hash, like app-cache-v3, and in activate loop through caches.keys() to delete any cache that doesn’t match the current version.
When you cache resources from another domain without CORS, for example an image from a third-party CDN, the response arrives as opaque: the browser lets it be cached but won’t let you read its size or status code. Caching too many opaque responses can exhaust the storage quota without your code ever knowing something failed.
Not every site needs a service worker. A banking dashboard that must show the real balance on every load, or a landing page a user visits only once, don’t justify the complexity of cache invalidation. As the classic industry joke, attributed to engineer Phil Karlton, goes: there are only two hard things in computer science: cache invalidation and naming things. A poorly versioned service worker turns the first one into a real production problem.
Comparison: Which Caching Strategy to Use
There’s no single correct way to cache with a service worker: the choice depends on how critical it is for the data to be up to date.
| Strategy | When to Use It | Advantage | Limitation |
|---|---|---|---|
| Cache First | Static assets that rarely change (CSS, fonts, icons) | Instant response, zero network latency | If you update the file without changing its name, the user sees the old version |
| Network First | Content that must be fresh but can degrade gracefully (news feed) | Always tries to fetch the latest version | If the network is slow, the user waits for the timeout before seeing the fallback |
| Stale While Revalidate | Content that changes little but should stay reasonably fresh (avatars, lists) | Immediate response from cache with silent background update | The user might briefly see data from a version behind |
| Network Only | Operations that should never be cached (payments, authentication) | Guarantees always-real data | Doesn’t work offline |
| Cache Only | Resources bundled in the build that never change at runtime | Zero network dependency | Requires republishing the service worker to update anything |
It’s worth not confusing the service worker with a regular Web Worker: the latter is meant for offloading heavy computation to another thread, but it doesn’t intercept network traffic or survive the tab closing. And compared to the old AppCache, the key difference is control: AppCache cached everything or nothing based on a declarative file, while the service worker decides case by case, request by request, with real code.
Going Deeper: Background Sync, Push, and Workbox
With the worker active and caching properly, the next level is the Background Sync API: it lets an action that failed due to lack of network, like submitting a form, get queued and retried on its own once the connection comes back, without the user having to do anything. Support is still uneven: Chrome and Edge implement it, Safari doesn’t.
The Push API combined with the service worker is what enables operating system notifications from a website, even with the browser closed on some operating systems. It requires the user to grant explicit permission and the backend to send the message through a push service, since the browser exposes a unique endpoint per device.
In production, almost nobody writes the fetch handler by hand like in the examples above. Workbox, Google’s library, offers declarative routes: you tell it which URL regular expression uses which strategy, and it generates the complete service worker.
flowchart LR
A["App"] --> B["Workbox Router"]
B --> C{"Request type"}
C -->|"Images"| D["CacheFirst"]
C -->|"JSON API"| E["NetworkFirst"]
C -->|"CSS and JS"| F["StaleWhileRevalidate"]
D --> G[("Cache Storage")]
E --> G
F --> G
Under the hood, Workbox still uses exactly the same pieces we saw earlier: caches.open(), event.respondWith(), and the install/activate cycle. The library doesn’t invent a new API, it packages the same patterns into reusable functions and adds automatic cache versioning.
Another detail worth distinguishing: Cache Storage stores complete request-response pairs, meant to serve network resources as-is. IndexedDB, on the other hand, is the browser’s database for storing structured data, like the contents of a form pending sync. A complete service worker usually uses both at once, each for what it’s meant for.
📖 Summary on Telegram: View summary
Your next step: create an empty sw.js file, register it with the Step 1 snippet, and confirm in Chrome DevTools, Application tab, Service Workers section, that it shows as activated before adding a single line of caching.
Frequently Asked Questions
Can a Service Worker access the DOM?
No. It runs on a separate thread, with no access to window or document; it can only intercept network requests, read and write to Cache Storage and IndexedDB, and communicate with open tabs via postMessage.
What happens if I update my sw.js?
The browser detects the change byte by byte, installs the new worker in parallel, and leaves it in the waiting state until every tab with the old worker closes, unless you use skipWaiting() to force the replacement.
Are Service Worker and Web Worker the same thing?
No. A Web Worker runs heavy computations on another thread but can’t intercept network traffic or survive the tab closing. The Service Worker does persist and acts as a network proxy even with the tab closed.
Does it work on Safari and iOS?
Safari has supported basic registration and Cache Storage for years, but it limits Background Sync and the Push API, especially outside apps added to the device’s home screen.
How much space can I cache?
It depends on the browser and the free disk space on the device. You can check the available quota for your origin by calling navigator.storage.estimate() from the console.
References
- MDN: Service Worker API: complete reference for the interface, events, and methods.
- web.dev: Service Workers: Google’s official guide on the lifecycle and caching patterns.
- W3C: Service Workers specification: the technical document that defines the standard.
- Workbox: documentation for the library that abstracts production caching strategies.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day. @programacion
Imagen destacada: Foto de Ferenc Almasi en Unsplash
0 Comments