Delegating cache work to the service worker
Some pages need to hand work off to the service worker without waiting for a result. Common examples include prefetching a batch of URLs the user might click next, or asking the worker to pull down a set of top articles for offline reading. These are non-critical tasks: useful if they succeed, harmless if they don't. Moving them off the main thread keeps that thread available for what matters more, like handling user interactions.
This pattern is a one-way form of window-to-service-worker communication. You can implement it with plain browser APIs or with the Workbox library, and it's often called imperative caching.
One production case comes from 1-800-Flowers.com. The retailer uses postMessage() to prefetch the top items on category pages, so product detail page navigations feel faster. Their approach is a mix: on page load, the service worker is asked to fetch and cache JSON data for the top 9 items. For the remaining items, a mouseover event listener triggers a fetch on demand when the cursor moves over an item. They store the JSON responses with the Cache API, and when the user clicks an item, the data is already available without hitting the network.
Using Workbox
Workbox ships a set of modules called workbox-window that run in the window context, complementing the Workbox packages that run in the service worker. To send a message from the page, first get a reference to the Workbox object for the registered service worker, then send the message declaratively—no need to fetch the registration, check for activation state, or think about the underlying messaging API:
const wb = new Workbox('/sw.js');
wb.register();
wb.messageSW({"type": "PREFETCH", "payload": {"urls": ["/data1.json", "data2.json"]}}); });
The service worker side implements a message event handler to listen for these messages. It can optionally return a response, though for one-way use cases like this it's usually not necessary:
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'PREFETCH') {
// do something
}
});
Using plain browser APIs
When Workbox doesn't fit, the same communication can be set up directly. The page calls postMessage() on the service worker interface:
navigator.serviceWorker.controller.postMessage({
type: 'MSG_ID',
payload: 'some data to perform the task',
});
The service worker listens with a message handler:
self.addEventListener('message', (event) => {
if (event.data && event.data.type === MSG_ID) {
// do something
}
});
The {type: 'MSG_ID'} payload is not strictly required, but it's a simple convention that lets a page send different kinds of instructions—for example, "prefetch these URLs" versus "clear storage"—and lets the service worker branch on that flag.
Because this is a fire-and-forget operation, failure doesn't disrupt the user flow. Going back to the 1-800-Flowers.com example: if the precache attempt fails, the page still navigates as usual, just a bit slower. The worker's success is an optimization, not a prerequisite.
A concrete prefetch example
Prefetching is the most common expression of imperative caching. Simple approaches include link prefetch tags, which keep resources in the browser cache for five minutes before normal Cache-Control rules apply, and combining that with a runtime caching strategy in the service worker to extend the lifetime of prefetched resources.
For basic cases—prefetching documents or specific JS/CSS assets—those techniques are the right tool. But when the logic gets more involved, such as parsing a prefetched JSON file to discover internal URLs, it's better to hand the whole task to the service worker. That brings two advantages:
- Fetching and any post-fetch processing run on a secondary thread, not the main thread.
- Multiple tabs can use the same reusable routine simultaneously without blocking, since they each send a message and move on.
Prefetching product detail pages
Start by sending an array of URLs to cache, using postMessage() on the service worker interface:
navigator.serviceWorker.controller.postMessage({
type: 'PREFETCH',
payload: {
urls: [
'www.exmaple.com/apis/data_1.json',
'www.exmaple.com/apis/data_2.json',
],
},
});
In the service worker, a message handler processes messages from any active tab:
addEventListener('message', (event) => {
let data = event.data;
if (data && data.type === 'PREFETCH') {
let urls = data.payload.urls;
for (let i in urls) {
fetchAsync(urls[i]);
}
}
});
The code above uses a helper, fetchAsync(), that iterates the URL array and issues a fetch for each:
async function fetchAsync(url) {
// await response of fetch call
let prefetched = await fetch(url);
// (optionally) cache resources in the service worker storage
}
You can rely on the response's caching headers when they exist. But many pages—product detail pages included—send Cache-control: no-cache. In those situations, explicitly storing the fetched resource in the cache can override that behavior, with the bonus of making the file available for offline use.
Going beyond the first level
Fetched JSON often contains URLs worth prefetching too. Take a grocery site where the endpoint returns product information including a hero image path:
{
"productName": "banana",
"productPic": "https://cdn.example.com/product_images/banana.jpeg",
"unitPrice": "1.99"
}
The fetchAsync() code can be modified to loop over the products and cache each hero image:
async function fetchAsync(url, postProcess) {
// await response of fetch call
let prefetched = await fetch(url);
//(optionally) cache resource in the service worker cache
// carry out the post fetch process if supplied
if (postProcess) {
await postProcess(prefetched);
}
}
async function postProcess(prefetched) {
let productJson = await prefetched.json();
if (productJson && productJson.product_pic) {
fetchAsync(productJson.product_pic);
}
}
Wrapping this in exception handling is wise for cases like 404s. But part of the appeal of this design is that the failure model is forgiving: a failed prefetch doesn't harm the page or the main thread's workload. You can also layer more elaborate post-processing onto the prefetched data, keeping the logic flexible and decoupled from the data shape.
Stronger together: related patterns
Imperative caching is just one way for a page and service worker to cooperate. Two closely related patterns cover the remaining directions of communication:
- Broadcast updates: the service worker initiates contact with the page to announce something important.
- Two-way communication: the page delegates a task, and the service worker reports progress back.



