HSTS and service workers overlap in ways that bite teams during deployment, local testing, and incident response.

The short version:

  • HSTS tells the browser to always use HTTPS for a host.
  • Service workers only work in secure contexts, with a few localhost exceptions.
  • If you get your HTTPS and redirect behavior wrong, service worker registration gets flaky fast.
  • If you get HSTS wrong, rollback gets painful because browsers cache the policy.

I’ve seen teams debug “random” service worker failures that were really bad TLS, mixed hostnames, or a stale HSTS policy. This guide is the practical version.

What HSTS actually changes for service workers

HSTS is the Strict-Transport-Security response header:

Strict-Transport-Security: max-age=31536000; includeSubDomains

When a browser sees that over HTTPS, it remembers: “for this host, only use HTTPS from now on.”

That matters for service workers because registration requires a secure context. If the browser upgrades requests to HTTPS before the network even happens, you avoid some ugly edge cases where:

  • a user types http://example.com
  • your app redirects to HTTPS
  • some assets or registration scripts still reference http://...
  • registration fails or becomes inconsistent

With HSTS in place, the browser upgrades the navigation before making the insecure request. That reduces one whole class of mistakes.

The baseline setup

If you serve a service worker, your site should usually send HSTS on all HTTPS responses for the main host.

Typical production header:

Strict-Transport-Security: max-age=31536000; includeSubDomains

If you want preload eligibility, you also need:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Official docs for HSTS are here:

If you want a quick check of your deployed header set, run a scan at headertest.com.

Copy-paste server configs

Nginx

Use always so error pages also send the header.

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/ssl/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/example/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/app;
    index index.html;

    location / {
        try_files $uri /index.html;
    }

    location = /sw.js {
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }
}

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Apache

<VirtualHost *:443>
    ServerName example.com
    ServerAlias www.example.com

    SSLEngine on
    SSLCertificateFile /path/fullchain.pem
    SSLCertificateKeyFile /path/privkey.pem

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    DocumentRoot /var/www/app
</VirtualHost>

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    Redirect permanent / https://example.com/
</VirtualHost>

Express

This is the simplest safe version:

import express from 'express';

const app = express();

app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  );
  next();
});

app.use(express.static('public'));

app.get('/sw.js', (req, res) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.sendFile(new URL('./public/sw.js', import.meta.url).pathname);
});

app.listen(3000);

If you’re behind a proxy or load balancer, make sure TLS actually terminates correctly upstream. HSTS does nothing if your cert chain is broken.

Registering a service worker safely

Basic registration:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
      try {
        const reg = await navigator.serviceWorker.register('/sw.js', {
          scope: '/'
        });
        console.log('SW registered', reg.scope);
      } catch (err) {
        console.error('SW registration failed', err);
      }
    });
  }
</script>

And a minimal sw.js:

self.addEventListener('install', event => {
  self.skipWaiting();
});

self.addEventListener('activate', event => {
  event.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request));
});

That’s enough to prove the secure-context path works.

The hostname trap

Service workers are scoped to an origin. HSTS is also host-based.

That means these are all meaningfully different:

  • https://example.com
  • https://www.example.com
  • https://app.example.com

A service worker registered on www.example.com does not control example.com. HSTS with includeSubDomains affects subdomains, but it does not merge origins.

This bites migrations. Example:

  • old app lived on www.example.com
  • new app lives on example.com
  • users still have a stale worker on www
  • your redirects and caches behave differently than expected

Pick a canonical host and be ruthless about redirecting everything else to it.

Example Nginx canonical redirect:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate     /etc/ssl/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/example/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    return 301 https://example.com$request_uri;
}

Don’t cache sw.js aggressively

This isn’t strictly an HSTS issue, but the two often get deployed together and then people wonder why updates don’t land.

The browser checks for service worker updates by requesting the script again. If you cache sw.js for a year, you make updates annoying.

Good header for sw.js:

Cache-Control: no-cache

For hashed static assets, cache hard. For the worker script itself, don’t.

Express example:

app.get('/sw.js', (req, res) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.type('application/javascript');
  res.sendFile('/absolute/path/to/public/sw.js');
});

Local development with HSTS

This is where people accidentally make their own life miserable.

A few rules:

  • localhost is treated specially by browsers for secure-context purposes.
  • Don’t set HSTS on random dev domains unless you really mean it.
  • Don’t preload anything until you are completely sure.

If you set HSTS on dev.example.test in a real browser profile, that browser will remember it. Then later, when your local TLS setup breaks, the browser refuses to connect and gives you no bypass if the cert is invalid enough. Now you’re debugging browser state instead of your app.

For local work, I prefer one of these:

  • http://localhost for basic service worker testing where supported
  • proper HTTPS on a dedicated dev hostname with a trusted local CA
  • separate browser profiles for testing HSTS behavior

If you need to clear an HSTS policy during testing, you usually have to remove the browser’s stored site security state. That process is browser-specific and annoying. Better to avoid poisoning your main profile.

Preload and service workers

HSTS preload is great for real production sites with stable HTTPS everywhere. It’s bad for half-finished migrations.

With preload, browsers effectively hardcode HTTPS-only behavior for your domain. That’s fantastic when you’re done. It’s brutal when some forgotten subdomain still serves broken TLS or an old asset path.

Service worker angle:

  • your app shell may work
  • some old worker-controlled route on a subdomain may fail hard
  • rollback is slow because preload removal is not instant

Use preload only when:

  • every subdomain that matters supports HTTPS correctly
  • redirects are clean
  • certificate management is boring and automated
  • you’re not still arguing about canonical hostnames

Mixed content still matters

HSTS upgrades top-level navigation for known hosts. It does not magically excuse sloppy asset URLs everywhere.

Bad:

navigator.serviceWorker.register('http://example.com/sw.js');

Good:

navigator.serviceWorker.register('/sw.js');

Bad:

<script src="http://example.com/app.js"></script>

Good:

<script src="/app.js"></script>

Use relative or explicit HTTPS URLs. Better yet, use origin-relative paths for your own assets.

Common failure patterns

1. HTTP redirect works, but service worker still fails

Usually one of:

  • invalid TLS certificate
  • wrong hostname
  • registration script loaded from a different origin
  • sw.js served with a bad content type or blocked path
  • page is not actually a secure context

2. HSTS enabled, now a subdomain is broken

Classic includeSubDomains problem. Someone enabled it before all subdomains were HTTPS-ready.

3. Worker won’t update

Usually caching on sw.js, not HSTS.

4. Rollback is painful

That’s normal with HSTS. Browsers cache the policy until max-age expires unless you replace it with:

Strict-Transport-Security: max-age=0

That only works if users can still reach the site over valid HTTPS and receive the new header. If certs are broken, you’re stuck.

A sane production checklist

Use this before shipping:

  • Serve the app and sw.js over valid HTTPS
  • Redirect all HTTP to HTTPS
  • Send Strict-Transport-Security on HTTPS responses
  • Pick one canonical hostname
  • Use includeSubDomains only when you mean it
  • Don’t cache sw.js aggressively
  • Use origin-relative URLs for worker registration and assets
  • Test with a fresh browser profile
  • Verify headers with browser devtools and a scanner like headertest.com

My default recommendation for most production apps with service workers is:

Strict-Transport-Security: max-age=31536000; includeSubDomains

Then add preload only after your domain estate is boring, consistent, and fully HTTPS-clean. Boring is what you want here. Boring means your service worker registration keeps working, your redirects make sense, and nobody is stuck in an HSTS-induced recovery mess on release day.