Picture two apps on the same phone during a train ride between Kalyan and Kasara, where the signal drops in and out of the ghats every few minutes. Open the IRCTC website in a normal browser tab the moment the bars vanish, and you get a blank page or a "no internet" error — the page simply cannot exist without a live connection to the server. Open WhatsApp during the same dead zone, and your old chats are still sitting right there, fully readable, and the messages you type get queued to send the instant signal returns. Both are pieces of software running on the same device. Why does one collapse the second the network disappears while the other keeps working?
The honest answer is not "WhatsApp is a special app and websites are not." The real answer is a specific browser feature called a service worker, and any website — including one built with plain HTML, CSS and JavaScript — can use the exact same trick. This chapter is about how that trick works, line by line.
Why an ordinary web page cannot survive without the network
To see why offline behaviour is not automatic, it helps to remember what actually happens when a browser loads a page. Every time you open a URL, the browser sends a request across the network to a server, waits for HTML, CSS, JavaScript and images to come back, and only then draws the page. There is no step in this process where the browser says "let me check if I've seen this before." Each visit is treated as brand new, even if you loaded the identical page ten seconds ago. If the network request fails — because you are in a tunnel, on a train, or your recharge has run out of data mid-month — there is nothing to draw, and the browser shows its own error page instead of your site.
This is not a bug. It is simply how the web was designed in the 1990s, when "always connected" was not something anyone assumed. For thirty years, this meant web pages were structurally incapable of working offline, no matter how well they were coded, because the browser itself had no concept of "keep a copy of this for later." Native apps like WhatsApp, on the other hand, are installed as files directly onto your phone's storage, so they always have something to show even with zero signal — they only need the network for new data, not for their own existence.
Service workers close this gap. They give a website the same power a native app has: the ability to decide, in JavaScript that you write, what should happen when a request cannot reach the network.
A concrete analogy before the formal definition
Imagine your school library keeps exactly one physical copy of a reference book, and every student who wants to check a fact has to walk to the library, in person, every single time — even to look up the same page they looked up yesterday. Now imagine one clever student, Aditi, decides to photocopy the ten pages her class uses most often and keeps that folder in her bag. From then on, when a classmate asks her a question, she first checks her folder. If the answer is in there, she hands it over instantly — no trip to the library needed. Only if the folder doesn't have it does she actually walk to the library, get the answer, and — this is the important part — photocopy that new page and add it to her folder before handing it over, so next time it's already there.
Aditi is not the library, and she is not the classmate asking the question. She sits between them, intercepting every request and deciding, using her own logic, whether to answer from her folder or go fetch fresh information. A service worker occupies exactly that position between your web page and the internet.
What a service worker actually is
A service worker is a JavaScript file that the browser runs separately from your web page, in its own background thread, and keeps alive even after you close the tab. Once it is registered, the browser lets it sit between every network request your page makes and the actual internet — it can inspect each request, decide to answer it from a local storage area called the Cache Storage API, let it go to the network normally, or even fabricate a response entirely. Because this logic is ordinary JavaScript that you control, you get to decide exactly what "offline" means for your app: show a cached version of the page, show a friendly "you're offline" message, or serve stale data with a warning — the browser no longer forces a blank error screen on you.
Three properties make a service worker different from any script you've written so far. First, it has no access to the DOM — it cannot touch document, cannot read or change anything on the visible page directly, because it doesn't run alongside the page; it runs as a separate worker thread that only communicates with the page through message-passing if needed. Second, it is event-driven: it wakes up only when a relevant event fires (install, activate, fetch, push), does its job, and the browser is free to shut it down in between to save memory — your code should never assume variables persist between events unless you explicitly save them to storage. Third, and most important for this chapter, once installed it keeps running its logic even when there is no network at all, which is the one property that makes offline behaviour possible.
Registering a service worker
Nothing happens automatically — your page has to explicitly tell the browser "here is a service worker file, please install it." That registration call lives in your normal page script, not inside the service worker itself:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((reg) => console.log('Service worker registered, scope:', reg.scope))
.catch((err) => console.log('Registration failed:', err));
});
}
Three things happen here in order. The if check exists because older browsers don't have navigator.serviceWorker at all, and a website should never crash just because a visitor is on an old browser — this is called a feature check. The window.addEventListener('load', ...) wrapper delays registration until the page has fully loaded, so the service worker installation doesn't compete with the page itself for the visitor's limited bandwidth on first visit. Finally, register('/sw.js') tells the browser to fetch that file and treat it as the controller for every page under its scope (by default, the folder it lives in and everything below it).
The lifecycle: install, then activate, then control
A service worker does not go straight from "registered" to "in charge of every request." It passes through a fixed sequence of states, and understanding this sequence is the difference between code that works and code that mysteriously seems to do nothing on the very first visit.
Install fires once, right after registration succeeds, and it is where you build your offline cache for the first time — typically by downloading and storing the core files your app needs (the "app shell": your HTML, CSS, JS, and logo, as opposed to per-page content that changes):
const CACHE_NAME = 'aici-app-shell-v1';
const FILES_TO_CACHE = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/logo.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(FILES_TO_CACHE);
})
);
});
Trace this line by line: caches.open(CACHE_NAME) creates (or reopens) a named storage bucket on the device — think of it as Aditi's folder, labelled with a version number. cache.addAll(FILES_TO_CACHE) then fetches every URL in that list from the network and stores each response inside the bucket, all in one batch. The whole thing is wrapped in event.waitUntil(...), which is a signal to the browser: "don't consider installation finished until this promise resolves." Without waitUntil, the browser might mark the service worker as installed before the files have actually finished downloading into the cache, leaving you with a false sense that offline support is ready when it isn't.
Activate fires next, once install has finished successfully, and is the natural place to clean up old cache versions left behind by a previous version of your service worker (covered in detail below). Fetch then fires repeatedly, once for every single request the page makes from that point onward — every image, every stylesheet, every API call — for as long as the service worker remains active.
Intercepting requests: the fetch event
This is the event where the offline magic actually happens. Every time the page asks for anything, the service worker gets first refusal on how to answer:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
);
});
Walk through what happens for a single request, say for /style.css, exactly as the browser would execute it. The fetch event fires with event.request holding the details of that request. event.respondWith(...) tells the browser "I am taking responsibility for answering this — don't do your normal network request." Inside, caches.match(event.request) searches every cache bucket this service worker owns for a stored response matching that exact URL. If Aditi's folder has the page — that is, if cachedResponse is not undefined — it is returned immediately, with zero network activity, so it works identically whether you have full signal or none at all. Only if nothing matches does the code fall through to fetch(event.request), which performs a genuine network request exactly as the browser would have done with no service worker present. This exact pattern — cache first, network as fallback — is called the cache-first strategy, and it is one of several caching algorithms you choose between depending on what kind of content you're serving.
Choosing a caching strategy: this is genuinely an algorithm choice
Not all content should be treated the same way, and picking the wrong strategy for the wrong data creates real bugs — like a news app that refuses to show today's headline because yesterday's is sitting in cache. Three strategies cover most real apps:
- Cache-first: check cache, only go to network if missing. Best for files that rarely change — your logo, your CSS, your app's core JavaScript. Fast and works offline, but can serve stale content if you forget to update the cache version.
- Network-first: try the network first; only fall back to cache if the network request fails. Best for content that must be fresh when possible — live scores, a chat feed, exam results — while still degrading gracefully to "last known data" when offline instead of showing nothing.
- Stale-while-revalidate: return the cached copy immediately for speed, but simultaneously fire a network request in the background to fetch a fresh copy and quietly update the cache for next time. Best for content like a news homepage, where instant loading matters more than the very latest second, but you still want it to catch up soon after.
Here is network-first written out, so you can compare its structure directly against the cache-first code above:
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((networkResponse) => networkResponse)
.catch(() => caches.match(event.request))
);
});
Notice it is almost a mirror image: try fetch first, and only reach for caches.match inside the .catch(), when the network call itself throws an error (which is exactly what happens when there is no connection at all).
A worked numeric example: why caching actually saves data
Suppose your app's shell — the HTML, CSS and JavaScript that make up its basic structure — totals 500 KB. Without a service worker, the browser re-downloads all 500 KB on every single visit, because, as established earlier, browsers treat every visit as new. If a student opens the app 20 times over one week, that is:
500 KB × 20 visits = 10,000 KB ≈ 9.8 MB of mobile data spent just on the app shell, before a single piece of actual content loads.
With a service worker using cache-first for the shell, only the very first visit downloads the 500 KB and stores it. The remaining 19 visits are served entirely from Cache Storage, at 0 KB of network use each. Total data spent that week: 500 KB × 1 = 500 KB. That is a saving of 10,000 − 500 = 9,500 KB, or roughly 9.3 MB, for one student in one week — data that costs real rupees on a limited monthly recharge, and time that matters when a 2G or patchy 4G connection makes every megabyte slow.
Diagram: what happens inside the fetch event
Correcting a common misconception: "installing" does not mean the very first visit works offline
A mistake many beginners make is assuming that the moment they write an install event handler, their app becomes offline-capable from the first time anyone ever opens it — even with no connection at all. That is impossible, and it is worth being precise about why. The service worker file itself has to be downloaded, parsed, and run before its install event can do anything, and the cache.addAll(...) call inside that handler has to actually fetch every listed file from the network before storing them. All of that requires an internet connection. A visitor's very first visit to your site, with no service worker registered yet, behaves exactly like a normal website — if their connection drops during that first load, there is nothing yet to fall back on. Offline support only exists for the second visit and onward, after installation has completed successfully at least once while online. This is precisely why real PWAs like Twitter Lite, built for users on inconsistent 2G and 3G networks in markets including India, still require that first successful load before their offline behaviour kicks in — the service worker cannot cache what it has never been able to download.
Correcting a second misconception: the service worker is not "extra code in your page"
Students who have only written JavaScript that manipulates buttons and text on a page often expect a service worker to behave the same way — that it can reach into the page and change what's on screen. It cannot. A service worker runs on a completely separate thread, with no reference to window or document, precisely so that it can keep running and intercepting network requests even after every tab using it has been closed. If a service worker genuinely needs to tell the open page something (say, "a new version is ready, please refresh"), it has to use an explicit messaging channel — postMessage() — the same mechanism you'd use to talk to a separate worker thread or even a separate browser tab, not a direct function call.
Updating an app: why cache versioning matters
Suppose you fix a bug in app.js and redeploy your site. Visitors who already installed the old service worker will not automatically see your fix, because their browser is still serving app.js straight out of the old cache bucket, cache-first, exactly as instructed. The fix is to change the cache name every time you change the cached files, and to delete old cache buckets during the activate event:
const CACHE_NAME = 'aici-app-shell-v2';
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => {
if (name !== CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
});
Trace it: caches.keys() returns the names of every cache bucket this service worker has ever created — in a long-lived app that might be ['aici-app-shell-v1', 'aici-app-shell-v2'] after one update. .map() walks that array and, for every name that does not match the current CACHE_NAME, calls caches.delete(name), removing the outdated bucket entirely. Promise.all(...) waits for every deletion to finish before considering activation complete. The browser automatically detects that sw.js has changed on your server the next time it checks, installs the new version alongside the old one, and — once no open tab is still using the old version — activates the new one and runs this cleanup.
From offline pages to installable apps: the Web App Manifest
A service worker alone gives you offline behaviour, but a Progressive Web App (PWA) goes one step further: it can be installed onto a phone's home screen and opened like a native app, with its own icon and no visible browser address bar. This second piece comes from a plain JSON file called the web app manifest, linked from your HTML with <link rel="manifest" href="/manifest.json">:
{
"name": "AICI Practice App",
"short_name": "AICI",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
display: "standalone" is what removes the browser's address bar and tab strip, making the app look native once launched from the home screen. start_url tells the browser which page to open first. The two icon sizes are required so the operating system can pick the right resolution for the home screen, the app switcher, and the splash screen shown while the app boots up.
A browser will only offer to "install" a site as an app — showing the "Add to Home Screen" prompt — when three conditions are all met: the site is served over HTTPS (so the manifest and service worker cannot be tampered with by anyone intercepting the connection on, say, public Wi-Fi), a valid manifest.json is linked, and a service worker with at least a working fetch handler is registered. That third requirement is deliberate: browser vendors decided that "installable" and "works offline" should be the same guarantee, so that installing an app never gives a user something that breaks the instant their signal drops.
Practice: active recall
- In your own words, explain why a normal web page (with no service worker) shows a browser error page instead of your content when the network fails, even though the page loaded perfectly five minutes earlier.
- Order these four service worker events in the sequence they actually fire for a brand-new visitor:
activate,fetch,install, registration vianavigator.serviceWorker.register(). - Trace this code for a request to
/data.jsonwhen that URL is not already in the cache. State, in order, every function call that runs and what each one returns:self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(event.request); }) ); }); - A weather app shows the current temperature. Should its API call use cache-first, network-first, or stale-while-revalidate? Justify your answer using what "stale" data would mean for this specific app.
- A student says: "I added the install event handler with cache.addAll(), so now my app works offline even for someone visiting my site for the very first time with no internet." Identify exactly what is wrong with this statement.
- An app's shell is 800 KB. Without caching, a user opens it 15 times in a month. With cache-first caching of the shell, how much data does the shell cost across those 15 opens, and how many KB does the service worker save compared to no caching at all?
- You changed
CACHE_NAMEfrom'app-v1'to'app-v2'after fixing a bug, but forgot to write any code inside theactivateevent. What will happen to the old cache bucket, and why does that matter for the phone's storage over time? - Explain, using the words "thread" and "postMessage," why a service worker cannot directly change an element's text on the visible page the way a normal
<script>tag can.
Summary
- A service worker is a JavaScript file the browser runs on a separate thread from your page; it can intercept every network request the page makes and decide how to answer it, even with no internet connection.
- It reaches this power through a fixed lifecycle:
register()in the page, theninstall(build the initial cache), thenactivate(clean up old caches), then repeatedfetchevents for every request going forward. - The Cache Storage API (
caches.open,cache.addAll,caches.match,caches.delete) is where files are actually stored on the device for offline use — it is separate from, and more powerful than, ordinary browser caching. - Cache-first, network-first, and stale-while-revalidate are three distinct algorithms for answering a request inside the
fetchevent; picking the right one depends on whether the content changes often and whether staleness is acceptable. - Offline support never applies to a visitor's very first load — the service worker must successfully install once, while online, before it has anything cached to fall back on.
- A service worker cannot touch the page's DOM directly; it communicates with open pages, if at all, only through explicit message-passing.
- Updating cached files requires bumping the cache name and deleting old buckets in
activate, or visitors keep receiving stale files from the previous version indefinitely. - A Progressive Web App adds a
manifest.jsonon top of a working service worker so the site can be installed to a home screen and launched like a native app; browsers require HTTPS, a valid manifest, and a working service worker together before offering that install prompt.