Home | Send Feedback | Share on Bluesky |

Angular project with Workbox service worker

Published: 22. June 2018  •  Updated: 21. March 2026  •  pwa, javascript

Angular ships with a built-in service worker, and that is often the right choice when you want simple offline support with Angular's conventions. If you need full control over caching, routing, updates, or additional browser APIs, a custom service worker is the better fit.

In this tutorial, we build that custom service worker with Workbox. Workbox provides production-ready libraries and tooling for service workers, so we can stay in TypeScript and use higher-level APIs instead of working directly with the low-level browser primitives.

This example uses a small but practical setup: precaching the Angular build output and caching icon requests at runtime. The service worker is written in TypeScript, built with Vite, and the precache manifest is injected with the Workbox CLI.

If you want to read more about Angular's built-in service worker, start with the current Angular docs: https://angular.dev/ecosystem/service-workers

Manifest

Every PWA needs a web app manifest. It tells the browser how the application should look and behave when installed, including the app name, icons, theme color, and start URL.

The recommended extension today is .webmanifest, but manifest.json is still perfectly valid. This sample keeps the file name manifest.json and stores it in src.

Copy manifest.json into the src folder. Copy the icons anywhere under src/assets. In this project, the icons live in src/assets/images/icons.


Tell Angular about the new file by adding "src/manifest.json" to the assets array. If you skip this step, Angular does not copy the manifest into the build output.

            "assets": [
              "src/favicon.ico",
              "src/assets",
              "src/manifest.json",
              "src/service-worker.js"
            ],

angular.json


Next, link the manifest from index.html.

  <link href="manifest.json" rel="manifest">

index.html


For current guidance on manifest fields, icons, and installability, see: https://web.dev/learn/pwa/web-app-manifest

Service Worker

Before writing the service worker, install the Workbox packages we need at runtime and the build tools we need during development.

npm install workbox-core workbox-precaching workbox-routing workbox-strategies workbox-window
npm install -D workbox-cli rimraf vite

Next, create the file src/service-worker.ts. It is a normal TypeScript entry point, so you can split it into multiple files and import other modules as needed.

Paste the following code into src/service-worker.ts.

/// <reference lib="es2018" />
/// <reference lib="webworker" />
import {precacheAndRoute} from 'workbox-precaching';
import {clientsClaim} from 'workbox-core';
import {registerRoute} from 'workbox-routing';
import {CacheFirst} from 'workbox-strategies';

declare const self: ServiceWorkerGlobalScope;
declare const process: {
  env: {
    NODE_ENV?: string;
  };
};

self.skipWaiting();
clientsClaim();

if (process.env['NODE_ENV'] === 'production') {
  registerRoute(
    /assets\/images\/icons\/icon-.+\.png$/,
    new CacheFirst({
      cacheName: 'icons'
    })
  );

  precacheAndRoute(self.__WB_MANIFEST);
}

service-worker.ts

The code is intentionally small. self.skipWaiting() asks a newly installed service worker to activate immediately, and clientsClaim() makes the active worker take control of open pages without waiting for the next navigation. That behavior is convenient, but it also means updates become active more aggressively, so make sure it matches your rollout strategy.

Read more about these methods on MDN: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim

And I recommend this article about the lifecycle of service workers: https://web.dev/articles/service-worker-lifecycle


The runtime route uses a cache-first strategy for icon requests. If an icon is already cached, the service worker serves it immediately. Otherwise, it fetches the icon from the network and stores it for the next request. This keeps the runtime cache focused on the assets that are actually used.

The important line is precacheAndRoute(self.__WB_MANIFEST);. It tells Workbox to precache the application files that are injected during the production build.

Those file names are not known ahead of time because Angular emits hashed bundle names during a production build. Workbox solves that by scanning the build output after Angular finishes and replacing self.__WB_MANIFEST with a generated array of URLs.

The process.env.NODE_ENV check is there for development. During a dev build, we do not inject a precache manifest, so the service worker skips the precaching block entirely. Vite replaces process.env.NODE_ENV at build time, and the local declare block keeps TypeScript happy during compilation.


Workbox offers far more than the modules used here. For broader coverage of its packages and recipes, start here: https://developer.chrome.com/docs/workbox/

Load Service Worker

The next step is to register the service worker in the browser.

You can do that directly from index.html, but registering it from TypeScript is usually more convenient because it keeps the logic in the Angular application and makes it easier to add diagnostics or environment checks.

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js');
  });
}
</script>

The simplest TypeScript version uses navigator.serviceWorker.register() after bootstrapApplication() resolves.

function loadServiceWorker(): void {
  // if (environment.production && ('serviceWorker' in navigator)) {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service-worker.js')
      .catch(err => console.error('Service worker registration failed with:', err));
  }
}

bootstrapApplication(AppComponent, {
  providers: []
})
  .then(() => loadServiceWorker())
  .catch(err => console.error(err));

main-simple.ts

If you only want service worker registration in production, wrap the registration call in your preferred environment check.


This sample uses workbox-window in main.ts instead.

Unlike the other Workbox packages, workbox-window runs in the page context. It simplifies registration and gives you convenient lifecycle events such as installed, waiting, and activated.

The following code shows the current setup.

function loadServiceWorker(): void {
  // if (environment.production && ('serviceWorker' in navigator)) {
  if ('serviceWorker' in navigator) {
    const wb = new Workbox('service-worker.js');

    wb.addEventListener('activated', (event) => {
      if (!event.isUpdate) {
        console.log('Service worker activated for the first time!');
      } else {
        console.log('Service worker activated!');
      }
    });

    wb.addEventListener('waiting', (event) => {
      console.log(`A new service worker has installed, but it can't activate` +
        `until all tabs running the current version have fully unloaded.`);
    });

    wb.addEventListener('installed', (event) => {
      if (!event.isUpdate) {
        console.log('Service worker installed for the first time');
      } else {
        console.log('Service worker installed');
      }
    });

    wb.register();
  }
}

bootstrapApplication(AppComponent, {
  providers: [provideZoneChangeDetection(),]
})
  .then(() => loadServiceWorker())
  .catch(err => console.error(err));

main.ts

The sample registers the worker whenever the browser supports service workers so the same code path can be exercised on localhost during development. The event listeners are optional, but they are useful when you want to inspect install and update behavior.

Workbox CLI configuration

precacheAndRoute() only describes where the generated manifest should be used. We still have to tell Workbox which files belong in that manifest.

Create workbox-config.js in the project root.

module.exports = {
  globDirectory: "dist/app/",
  globPatterns: ["**/*.{css,eot,html,ico,jpg,js,json,png,svg,ttf,txt,webmanifest,woff,woff2,webm,xml}"],
  globFollow: true,
  globIgnores: ['3rdpartylicenses.txt', 'assets/images/icons/icon-*.png'],
  dontCacheBustURLsMatching: new RegExp('.+.[a-f0-9]{20}..+'),
  maximumFileSizeToCacheInBytes: 5000000,
  swSrc: "dist/app/service-worker.js",
  swDest: "dist/app/service-worker.js"
};

workbox-config.js

This file tells the Workbox CLI what to scan, what to ignore, and where the built service worker lives.

You find a detailed description of the supported injectManifest options here: https://developer.chrome.com/docs/workbox/modules/workbox-build

In this example, Workbox scans dist/app/ and precaches the files that match globPatterns but not globIgnores.

The icon files are excluded on purpose. They are handled by the runtime registerRoute() logic, so only the icons the browser actually requests end up in the cache.

Angular already emits versioned bundle names for production, so dontCacheBustURLsMatching tells Workbox not to add an extra cache-busting layer to URLs that already contain a hash.

Build the service worker with Vite

Workbox injectManifest does not compile or bundle your service worker. It only injects the precache list into an existing JavaScript file. Because our service worker is written in TypeScript and imports Workbox packages, we need a dedicated build step first.

For that build step, this project uses Vite.

Create the file vite.service-worker.config.ts in the project root.

import path from 'node:path';
import {defineConfig} from 'vite';

export default defineConfig(({mode}) => {
  const isProduction = mode === 'production';

  return {
    build: {
      emptyOutDir: false,
      lib: {
        entry: path.resolve(__dirname, 'src/service-worker.ts'),
        fileName: () => 'service-worker.js',
        formats: ['iife'],
        name: 'ngWorkboxServiceWorker'
      },
      minify: isProduction ? 'esbuild' : false,
      outDir: path.resolve(__dirname, isProduction ? 'dist/app' : 'src'),
      sourcemap: !isProduction,
      target: 'es2022'
    },
    define: {
      'process.env.NODE_ENV': JSON.stringify(mode)
    }
  };
});

vite.service-worker.config.ts

This configuration builds src/service-worker.ts as a single IIFE bundle named service-worker.js. In production mode, the output goes to dist/app. In development mode, it goes to src, which allows Angular to serve the generated file as an asset.

The define block sets process.env.NODE_ENV, which is what the service worker uses to decide whether to execute the precaching code.

Scripts

Now we need scripts that tie the Angular build, the Vite service worker build, and the Workbox injection step together.

    "build": "ng build",
    "postbuild": "npm run sw-prod-vite",
    "serve-dist": "ws --hostname localhost -d dist/app -p 1234 -o --log.format stats",
    "sw-dev-vite": "rimraf ./src/service-worker.js \u0026\u0026 vite build --config ./vite.service-worker.config.ts --mode development --watch",
    "sw-prod-vite": "rimraf ./dist/app/service-worker.js \u0026\u0026 vite build --config ./vite.service-worker.config.ts --mode production \u0026\u0026 workbox injectManifest ./workbox-config.js",

package.json

sw-prod-vite removes any previous build output, runs the Vite production build for the service worker, and then calls workbox injectManifest.

To trigger that automatically after every Angular production build, use postbuild.

    "build": "ng build",
    "postbuild": "npm run sw-prod-vite",

package.json

With that in place, npm run build runs the Angular build first and then automatically starts postbuild.

If everything is configured correctly, the final file ends up at dist/app/service-worker.js and contains a generated precache array instead of the literal string self.__WB_MANIFEST.

If you still see self.__WB_MANIFEST in the built file, check the output of the Vite build and the include and exclude patterns in workbox-config.js.

Test production build

To test the production build, serve the generated files over HTTP. Service workers are not available via the file:// protocol.

In this project, I use local-web-server.

npm install -D local-web-server

You can start it directly with npx:

npx ws --hostname localhost -d dist/app -p 1234

Or add a script to package.json:

    "serve-dist": "ws --hostname localhost -d dist/app -p 1234 -o --log.format stats",

package.json

and then run:

npm run serve-dist

That command serves dist/app from localhost:1234 and opens the browser automatically.

Development

Production precaching is only half of the story. During local development, ng serve keeps the Angular build in memory, which means there is no finished dist/app output for Workbox to scan.

That is why the service worker only runs the precaching code in production.

declare const self: ServiceWorkerGlobalScope;
declare const process: {
  env: {
    NODE_ENV?: string;
  };
};

self.skipWaiting();
clientsClaim();

if (process.env['NODE_ENV'] === 'production') {
  registerRoute(
    /assets\/images\/icons\/icon-.+\.png$/,
    new CacheFirst({
      cacheName: 'icons'
    })
  );

  precacheAndRoute(self.__WB_MANIFEST);
}

service-worker.ts

For development, the dedicated Vite build runs in watch mode and writes src/service-worker.js whenever src/service-worker.ts changes.


Angular does not serve that generated file automatically. To make it available to the browser, add src/service-worker.js to the Angular assets array.

            "assets": [
              "src/favicon.ico",
              "src/assets",
              "src/manifest.json",
              "src/service-worker.js"
            ],

angular.json

That same entry also copies src/service-worker.js into the production output. This is fine because the production build later overwrites dist/app/service-worker.js with the Vite-generated production bundle before Workbox injects the manifest.


Because src/service-worker.js is generated, add it to .gitignore.

/service-worker.js

.gitignore


The last piece is starting the Angular dev server and the Vite watch build together.

Install npm-run-all so both processes can run in parallel.

npm install -D npm-run-all

Then add a script that starts the Vite watch build for the service worker and another script that runs it in parallel with ng serve.

    "dev": "npm-run-all --parallel sw-dev-vite start",
    "start": "ng serve -o",
    "build": "ng build",
    "postbuild": "npm run sw-prod-vite",
    "serve-dist": "ws --hostname localhost -d dist/app -p 1234 -o --log.format stats",
    "sw-dev-vite": "rimraf ./src/service-worker.js \u0026\u0026 vite build --config ./vite.service-worker.config.ts --mode development --watch",
    "sw-prod-vite": "rimraf ./dist/app/service-worker.js \u0026\u0026 vite build --config ./vite.service-worker.config.ts --mode production \u0026\u0026 workbox injectManifest ./workbox-config.js",

package.json

npm run dev now starts both background processes: Angular serves the app, and Vite rebuilds the service worker whenever you change it.


This setup gives you a flexible starting point for a custom Angular service worker. The code stays in TypeScript, Workbox handles the common caching primitives, Vite produces the bundle, and injectManifest keeps the precache list aligned with the Angular build output.

If you are interested in a more extensive example, check out my blog post about background sync. The example there builds on the same approach, but uses a more advanced service worker.