Key takeaways
- Service workers on Shopify are blocked by scope. A service worker can only control the directory it is served from, and Shopify serves theme assets under
/cdn/shop/t/<id>/assets/, not the store root.- The header that widens scope,
Service-Worker-Allowed, is on Shopify’s documented list of headers stripped from App Proxy responses, so the standard proxy workaround does not give you root control.- Checkout runs on your store domain, so a root-scoped service worker would sit in front of payment pages. That is a line you should not cross.
- A bad service worker is close to unremovable. It lives on the shopper’s device, keeps serving stale pages, and only a self-destructing replacement at the same URL clears it.
- Most Shopify stores should not do this. Image, script, and theme-level fixes deliver the same speed win with none of the failure modes.
Service workers on Shopify come up whenever someone reads about offline-first sites, precaching, or web push and wonders why their store cannot do the same. The short answer is that Shopify’s hosting model puts a hard limit on where you can serve the worker file from, and that limit decides almost everything else.
This guide covers what a service worker is, the exact Shopify constraint and which workarounds are real, the handful of use cases that survive that constraint, working code with the caveats attached, how to kill a bad worker, and why most stores are better off spending the same effort elsewhere.
Why you can trust us
We have been in the Shopify space for over four years and have worked with hundreds of Shopify brands on their storefronts. Jacques has over 15 years of development experience. We build Fudge, an AI storefront editor with a 5.0 rating on the Shopify App Store and Built for Shopify status, so we work in the theme layer every day and see what the platform actually permits.
What is a service worker?
A service worker is a JavaScript file the browser runs in the background, separate from any page. Once installed, it sits between the site and the network and can answer requests from a cache instead of the server.
It is not a script tag. It has no DOM access, it survives page navigations, and it keeps running after the tab closes. That independence is what makes it useful and what makes it dangerous.
The lifecycle
Four phases matter. Getting them wrong is how stores ship stale content to shoppers.
| Phase | What happens | The trap |
|---|---|---|
| Install | Fires once per worker version. Usually where you precache files | If install fails, the worker never takes over |
| Waiting | A new worker sits idle until every tab running the old one closes | Shoppers can run last week’s worker for days |
| Activate | Old worker is gone. The place to delete old caches | Skipping cleanup leaves orphaned caches on the device |
| Fetch | The worker intercepts requests it is allowed to see | A bad rule here breaks the store silently |
Two details catch people out.
The first page load is never controlled. A page has to be loaded by a controlling worker before that worker sees its requests. Register on page one, and the caching only starts on page two.1
Updates are checked, not pushed. The browser refetches the worker script on navigation and on functional events, and it will not skip that check for longer than 24 hours. Most browsers ignore your caching headers on the script itself. You still cannot force an update onto a device that never visits again.1
skipWaiting() and clients.claim() shortcut the waiting phase, at the cost of running new worker code against pages rendered by the old version.
The Shopify constraint: scope
Here is the whole problem in one rule. A service worker can only control URLs at or below the path it is served from. A worker at /sw.js controls the entire site. A worker at /assets/sw.js controls /assets/ and nothing else. The script also has to be same-origin with the page, or registration throws a SecurityError.2
There is one escape hatch in the spec: the server can send a Service-Worker-Allowed response header on the worker script to widen the maximum scope.2 Hold that thought.
Now look at where Shopify puts your files.
Theme assets are same-origin but deeply nested. Shopify serves storefront theme assets from the store’s own domain under a path like /cdn/shop/t/4159/assets/theme.js, which you can confirm by viewing source on any live store.3 Same origin is good news. The path is not. A worker uploaded to your theme’s assets folder gets a scope of /cdn/shop/t/4159/assets/, which controls your theme files and no store pages at all.
Content > Files is worse. Files uploaded there are served from cdn.shopify.com, a different origin entirely. Registering from there fails outright.
The store root is not yours. Request /sw.js on any Shopify store and you get a 404. The Online Store does not let merchants place arbitrary files at the domain root. robots.txt.liquid is the rare exception, and it only produces robots.txt.
And you cannot add the header. Shopify’s CDN serves theme assets with a long Cache-Control and no Service-Worker-Allowed. You have no way to change response headers on files Shopify serves.
The scope rule plus Shopify’s file hosting means the standard root-scoped service worker is simply not available on the Online Store.
Which workarounds actually work?
This is where most articles on the topic go wrong, usually by repeating advice that Shopify closed years ago. Here is the honest state of each route.
| Route | Does it work? | Reality |
|---|---|---|
Upload sw.js to the theme’s assets folder | Registers, but useless | Scope is limited to the theme asset directory |
| Upload to Content > Files | No | Served from cdn.shopify.com, cross-origin |
Place sw.js at the domain root | No | The Online Store has no root file hosting |
App Proxy plus Service-Worker-Allowed: / | No | Shopify strips that exact header |
| App Proxy, scoped to the proxy subpath | Yes, narrowly | Real scope, but only over /apps/your-path/ |
| Reverse proxy in front of the store | Technically, but unsupported | Shopify does not support proxying your domain |
| Headless storefront you host yourself | Yes | You own the root, so you own the scope |
Why the App Proxy route is dead for root scope
An App Proxy maps a storefront path such as /apps/your-path to a server you run. Prefixes are limited to apps, a, community, or tools, so the mount point is never the root.4
You could serve sw.js from your proxy and send Service-Worker-Allowed: / to widen its scope. Shopify blocks it. The App Proxy documentation lists the response headers Shopify removes for security reasons, and Service-Worker-Allowed is on that list alongside Set-Cookie, Server, and X-Powered-By.4
This was a deliberate change. Merchants and app developers have reported the header being stripped since 2021, and it broke a wave of push notification and client-side caching apps at the time. Root service workers are not an oversight you can argue your way around.
What still works is a service worker registered at the proxy subpath, controlling /apps/your-path/. No special header needed, because that scope is already at or below the script’s own location. It cannot cache product pages. It can still receive push messages, which we come back to below.
Why the reverse proxy route is a bad idea
You can, in principle, put a CDN or edge worker in front of your domain and serve /sw.js yourself. Shopify’s own domain troubleshooting documentation says it does not support setups such as Cloudflare DNS proxying and Orange-to-Orange, and warns that they can break when either side changes.5 Putting an unsupported proxy in front of a store to enable an optional caching layer is a poor trade.
Headless is the one clean answer
If you run a custom storefront, whether that is Hydrogen on Oxygen or your own front end against the Storefront API, you control the server that answers the root path. You can serve /sw.js with any headers you want and register it at scope /. Every constraint in this section is a constraint of the Online Store’s hosting, not of Shopify as a commerce backend.
What can a service worker realistically do on a Shopify store?
Assume for a moment you cleared the scope problem. Four use cases come up. They are not equally worthwhile.
Caching static assets and fonts. The genuine one. Versioned theme assets and self-hosted fonts are immutable, so a cache-first rule is safe and gives repeat visitors near-instant loads. The catch is that Shopify’s CDN already serves theme assets with a one-year Cache-Control, so the browser HTTP cache is doing most of this work already. The incremental gain is smaller than it looks.
An offline fallback page. Cheap and low-risk. Instead of the browser’s error page, a shopper who loses signal sees your branded page. It does not sell anything. It is a polish item.
Background sync for a form. A newsletter signup or a review submission made offline gets retried when the connection returns. Useful in genuinely poor-connectivity markets, close to pointless elsewhere. Support is not universal across browsers, so treat it as an enhancement with a normal submit path underneath.
Push notifications. A push subscription is attached to the service worker registration, not to its scope, so a worker at a narrow path can still receive push. The real blockers sit elsewhere: you need a permission prompt shoppers overwhelmingly decline, iOS only delivers web push to sites the user has added to the Home Screen, and most merchants end up using an App Store push app rather than building this. Check the live listing of any push app for current plans before you commit.
For the mobile audience these use cases target, the wins are usually elsewhere. See our guide to Shopify mobile speed for the changes that actually move mobile numbers.
The code, with the caveats attached
If you are running headless, or you have a staging environment where you own the root, this is the minimum viable pair of files.
Registration
// In your layout, after the page has settled.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js", { scope: "/" })
.then((reg) => console.log("Service worker scope:", reg.scope))
.catch((err) => console.error("Registration failed:", err));
});
}
Caveat: on a standard Online Store this fails, because /sw.js returns a 404. Requesting /sw.js on your own domain is the fastest way to confirm that before you write anything else. If you are adding this alongside other scripts, our guide to adding custom JavaScript in Shopify covers where theme code belongs.
A minimal fetch handler
Cache-first for versioned assets, network-first for documents, and an explicit refusal to touch anything transactional.
const VERSION = "v1";
const ASSET_CACHE = `assets-${VERSION}`;
const PAGE_CACHE = `pages-${VERSION}`;
// Never intercept these. Cart, account, checkout and app routes stay live.
const NEVER_HANDLE = [
/^\/checkouts?\//,
/^\/cart/,
/^\/account/,
/^\/apps\//,
/^\/wpm/,
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(PAGE_CACHE).then((c) => c.add("/offline")));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => !k.endsWith(VERSION)).map((k) => caches.delete(k))
)
)
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== "GET") return;
if (url.origin !== self.location.origin) return;
if (NEVER_HANDLE.some((re) => re.test(url.pathname))) return;
// Versioned theme assets are immutable, so cache-first is safe.
if (url.pathname.startsWith("/cdn/shop/t/")) {
event.respondWith(
caches.match(req).then((hit) => {
if (hit) return hit;
return fetch(req).then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(ASSET_CACHE).then((c) => c.put(req, copy));
}
return res;
});
})
);
return;
}
// Documents go to the network first. The cache is an offline fallback only.
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(PAGE_CACHE).then((c) => c.put(req, copy));
return res;
})
.catch(() =>
caches.match(req).then((hit) => hit || caches.match("/offline"))
)
);
}
});
Caveats that matter more than the code:
NEVER_HANDLEis the safety rail. Cart, account, and checkout paths must reach the server every time. Returning a cached cart is a support ticket. Returning a cached checkout step is worse.- Never cache-first an HTML document. Prices, stock, and promotions change. A cached product page will quote yesterday’s price to a shopper who is looking at today’s ad.
- Bump
VERSIONon every change. The activate handler is what stops old caches accumulating on shopper devices. - Test against a real store, not a template. Themes and apps introduce their own fetch patterns, and Shopify adds routes you did not write.
The risks, stated plainly
This is the section to read twice.
Stale content ships to real shoppers. The failure mode is not “the site is slow.” It is a shopper seeing a sold-out product as available, or a sale banner that ended a week ago, because their device is serving a cached copy. You will not see it in your own browser.
Checkout is on your domain. Since Shopify moved checkout off checkout.shopify.com, checkouts occur on the shop domain the customer is browsing.6 A root-scoped worker would sit in the request path for payment pages. Do not put custom caching code in front of checkout. Shopify keeps checkout locked down for PCI reasons, which is also why analytics has to run through the sandboxed Web Pixels API rather than arbitrary scripts. Treat that boundary as absolute here.
Cart state breaks in ways that are hard to reproduce. Cart routes return JSON that changes on every interaction. Cache one response and add-to-cart starts lying about its contents.
A bad worker is close to unremovable. This is the risk that separates service workers from every other front-end mistake. The worker lives on the shopper’s device. You cannot reach it, you cannot see it, and rolling back your theme does not touch it. It will keep serving whatever it cached until that specific browser fetches a replacement from the exact URL the worker was registered from.
Shopify’s asset paths make that worse. The /cdn/shop/t/<id>/ segment is tied to the theme. Publish a different theme and the old worker’s script URL can stop resolving. Per the spec, a failed update fetch aborts the update and leaves the existing worker in place, so your kill switch has nowhere to live.
You are layering a cache on a cache. Shopify already runs a Cloudflare-backed CDN in front of your storefront and versions asset URLs automatically.3 A hand-rolled cache on top adds a second invalidation problem without removing the first. For where the real performance gaps sit, see our state of Shopify performance in 2026.
How to unregister a bad service worker
If one is already live, work through these in order.
1. Confirm what is installed
In Chrome DevTools, open Application > Service Workers to see the registration, its scope, and its script URL. Application > Storage > Cache Storage shows what it has cached. Note the exact script URL. Everything below depends on it.
2. Ship a self-destructing worker
The only reliable removal is to replace the worker file at the same URL with one whose entire job is to delete itself.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((client) => client.navigate(client.url));
})()
);
});
skipWaiting() activates it immediately, the activate handler clears every cache and unregisters, then each open tab reloads without a controller. Also remove the registration call from your theme so nothing re-registers.
It only runs when the shopper next visits. The browser will check the script within 24 hours of activity, but a device that never returns keeps the old worker forever. There is no server-side purge.
3. Know what Clear-Site-Data can and cannot do
The Clear-Site-Data: "storage" response header unregisters service workers for the origin.7 It is the clean fix on a site where you control response headers. On the Shopify Online Store you do not, so this is available to headless storefronts only.
4. For an individual shopper
Support can walk one person through DevTools Application > Storage > Clear site data, or a private window. That is triage for a complaint, not a fix.
Most stores should not do this. Here is what to do instead.
The verdict is straightforward. On the Shopify Online Store, a useful service worker is not achievable within the platform’s rules, and the version that is achievable is not worth the risk. Scope confines you to a directory that controls nothing, the header that would fix it is stripped, and the reverse-proxy route is unsupported. Build one anyway and you are one mistake away from stale prices you cannot recall.
Headless storefronts are a different conversation. If you own the root, the normal web rules apply and the code above is a reasonable starting point.
For everyone on the Online Store, the same effort spent on the theme returns more:
- Cut the app scripts you are not using. Third-party JavaScript is the largest controllable cost on most stores, and removing a script beats caching it.
- Fix images first. Correct sizing, modern formats, and an eagerly loaded hero. Our Shopify lazy load images guide covers the ordering that matters.
- Remove render-blocking resources and minify what remains. See how to minify CSS and JavaScript in Shopify for what Shopify already handles and what it does not.
- Trim the theme itself. The full sequence is in how to speed up a Shopify theme.
Each of these is reversible from the admin. None of them can strand a shopper on a cached copy of your store.
That reversibility is the reason we built Fudge to write native theme code rather than inject a runtime layer. Changes land as Liquid, CSS, and JavaScript in your theme, so you can read them, roll them back, and keep them after you stop paying us. The same applies to anything else you build with the Shopify store editor.
FAQ
Not usefully on the standard Online Store. A service worker only controls the directory it is served from, and Shopify serves theme assets under a nested path like /cdn/shop/t/<id>/assets/ with no way to place a file at the store root. You can register one, but its scope covers theme files rather than store pages. Headless storefronts you host yourself have no such limit.
Two causes cover almost every case. If you uploaded the file under Content > Files it is served from cdn.shopify.com, a different origin, which throws a SecurityError. If you requested a root path such as /sw.js it returns a 404, because the Online Store does not host merchant files at the domain root.
No. Widening a worker's scope requires the Service-Worker-Allowed response header, and Shopify's App Proxy documentation lists that header among the ones it strips for security reasons. A worker served from the proxy still works at the proxy subpath, so it can receive push messages, but it cannot control product or collection pages.
It can. Checkout runs on your store domain rather than checkout.shopify.com, so a root-scoped worker would sit in the request path for payment pages. Never intercept /checkouts/, /cart, or /account routes. Shopify keeps checkout locked down for PCI reasons and that boundary should be treated as absolute.
Replace the worker file at the exact same URL with a self-destructing version that calls skipWaiting on install, deletes every cache, then calls self.registration.unregister and reloads open tabs. Also remove the registration call from your theme. It only takes effect when each shopper next visits, and there is no way to purge it server-side.
Less than you would expect. Shopify already serves theme assets through a Cloudflare-backed CDN with a one-year cache lifetime and automatic versioning, so the browser HTTP cache handles most repeat-visit gains. Cutting unused app scripts, fixing image sizing, and removing render-blocking resources return more with no risk of stale content.
Web push requires a service worker registration, but the subscription is tied to the registration rather than its scope, so a worker at a narrow App Proxy path is enough. The harder limits are the permission prompt most shoppers decline and iOS only delivering web push to sites added to the Home Screen. Most merchants use an App Store push app instead of building it.
Footnotes
-
web.dev, “The service worker lifecycle” - covers install, waiting, activate, the uncontrolled first load, and the browser’s update checks capped at 24 hours. https://web.dev/articles/service-worker-lifecycle ↩ ↩2
-
MDN, “ServiceWorkerContainer: register() method” - default scope, the maximum allowed scope restriction, the Service-Worker-Allowed header, and the same-origin SecurityError. https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register ↩ ↩2
-
Shopify Dev, “The Shopify platform” - Shopify’s CDN is backed by Cloudflare, and some storefront assets are served from the storefront domain under /cdn rather than cdn.shopify.com. https://shopify.dev/docs/storefronts/themes/best-practices/performance/platform ↩ ↩2
-
Shopify Dev, “About app proxies and dynamic data” - lists the allowed prefixes and the response headers Shopify removes, which include Service-Worker-Allowed. https://shopify.dev/docs/apps/build/online-store/app-proxies ↩ ↩2
-
Shopify Help Center, “Troubleshooting issues with domains” - Shopify does not support setups such as Cloudflare DNS proxying and Orange-to-Orange. https://help.shopify.com/en/manual/domains/troubleshoot-issues-with-domains ↩
-
Shopify Dev changelog, “Checkouts will occur at the shop domain instead of checkout.shopify.com”. https://shopify.dev/changelog/checkouts-will-occur-at-the-shop-domain-instead-of-checkout-shopify-com ↩
-
MDN, “Clear-Site-Data” - the “storage” directive unregisters service workers for the origin. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Clear-Site-Data ↩