Solving the Cache Problem with Angular Service Worker
Deployed a critical bug fix to production. Users still report the same bug hours later. Sound familiar? Their browsers are serving cached JavaScript from the old version, and nobody's hitting refresh.
The Real Problem
After deploying updates, users continue running outdated code until they manually refresh. This causes:
- Breaking changes hit cached old code
- Support tickets flood in after every deployment
- "Have you tried Ctrl+F5?" becomes your standard response
- Lost productivity from users stuck on broken versions
Why Service Workers?
Service Workers sit between your app and the network, controlling cache behavior. With Angular's Service Worker:
- Automatically detect new versions
- Cache assets intelligently
- Notify users when updates are available
- Handle the update process gracefully
Key insight: Don't force updates. Let users choose when to reload. They might be mid-work.
Implementation
Step 1: Add Angular PWA
ng add @angular/pwa
This adds Service Worker support and generates initial config files.
Step 2: Configure Caching Strategy
Create or update ngsw-config.json:
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**", "/*.(svg|jpg|jpeg|png|webp|woff|woff2)"]
}
}
]
}
Strategy:
prefetch: Download immediately (critical app files)lazy: Download on first use (images, fonts)
Step 3: Enable in Angular Config
Update angular.json production configuration:
"production": {
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
}
Step 4: Build Update Detection Service
Create service-worker-update.service.ts:
import { Injectable } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { Router, NavigationEnd } from '@angular/router';
import { filter, switchMap, catchError, take } from 'rxjs/operators';
import { timer, EMPTY, Subject, merge } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable()
export class ServiceWorkerUpdateService {
private _updateAvailable = false;
private _dialogOpen = false;
private readonly HOUR_MS = 60 * 60 * 1000; // Check every hour
private readonly _destroy$ = new Subject<void>();
constructor(
private readonly _swUpdate: SwUpdate,
private readonly _router: Router,
private readonly _dialogService: DialogService
) {}
public checkForUpdates(): void {
if (!this._swUpdate.isEnabled) {
console.log('[SW] Service Worker not enabled');
return;
}
// Listen for version updates
this._swUpdate.versionUpdates
.pipe(
filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'),
takeUntilDestroyed()
)
.subscribe(() => {
console.log('[SW] New version available');
this._updateAvailable = true;
this._promptUpdate();
});
// Check periodically (every hour)
timer(0, this.HOUR_MS)
.pipe(
switchMap(() => this._swUpdate.checkForUpdate()),
catchError((err) => {
console.error('[SW] Update check failed:', err);
return EMPTY;
}),
takeUntilDestroyed()
)
.subscribe();
// Check on navigation
this._router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe(() => {
if (this._updateAvailable) {
this._promptUpdate();
} else {
this._swUpdate
.checkForUpdate()
.catch((err) => console.error('[SW] Navigation check failed:', err));
}
});
}
private _promptUpdate(): void {
if (this._dialogOpen) return;
this._dialogOpen = true;
const dialog = this._dialogService.open(null, {
header: 'Update Available',
text: 'A new version is available. Reload now?',
submitButton: 'Reload',
cancelButton: 'Later',
closeOnEscape: false,
hideOnOutsideClick: false,
});
// Handle reload
dialog.submitted.pipe(take(1)).subscribe(() => this._reloadApp(dialog));
// Handle dismiss
merge(dialog.canceled, dialog.closed)
.pipe(take(1))
.subscribe(() => this._closeDialog(dialog));
}
private _reloadApp(dialog: DialogComponent): void {
this._swUpdate
.activateUpdate()
.then(() => {
console.log('[SW] Update activated, reloading...');
location.reload();
})
.catch((err) => {
console.error('[SW] Activation failed:', err);
this._closeDialog(dialog);
});
}
private _closeDialog(dialog: DialogComponent): void {
dialog.close();
this._dialogOpen = false;
}
public destroy(): void {
this._destroy$.next();
this._destroy$.complete();
}
}
Key decisions:
- Check every hour (not too aggressive)
- Check on navigation (catch updates during normal use)
- User controls when to update (no forced reloads)
- Single dialog instance (avoid spam)
Step 5: Initialize in App Component
// app.component.ts
export class AppComponent implements OnInit, OnDestroy {
constructor(private readonly _swUpdateService: ServiceWorkerUpdateService) {}
public ngOnInit(): void {
this._swUpdateService.checkForUpdates();
}
public ngOnDestroy(): void {
this._swUpdateService.destroy();
}
}
Step 6: Register Service Worker
In app.config.ts:
import { ServiceWorkerModule } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
importProvidersFrom(
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
// Register after app stabilizes (doesn't block initial load)
registrationStrategy: 'registerWhenStable:3000',
})
),
ServiceWorkerUpdateService,
],
};
Common Pitfalls
1. Checking Updates Too Frequently
Bad:
timer(0, 60000); // Every minute → hammers server
Good:
timer(0, this.HOUR_MS); // Every hour + navigation checks
2. Forcing Reload Without Consent
Bad:
this.swUpdate.activateUpdate().then(() => location.reload());
// User loses unsaved work
Good:
// Show dialog, let user choose when
if (userClickedReload) {
this.swUpdate.activateUpdate().then(() => location.reload());
}
3. Enabling in Development
Service Worker aggressively caches. During development:
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production, // Disable in dev
});
Use ng serve which automatically disables Service Worker.
4. Not Handling Errors
Always wrap update checks in error handlers:
this.swUpdate.checkForUpdate().catch((err) => console.error('[SW] Check failed:', err));
Testing in Production
Local Testing
# Build production
ng build --configuration production
# Serve with HTTP server
npx http-server dist/your-app -p 8080
# Open localhost:8080
Verify in DevTools:
- Open DevTools → Application → Service Workers
- Check "Update on reload" for testing
- Make a change, rebuild, refresh
- Should see new version detected
Production Verification
- Deploy new version
- Open app in browser
- Check console for
[SW] New version available - Verify dialog appears
- Click "Reload" → should load new version
Troubleshooting
Service Worker Not Registering
Check:
- Must be HTTPS (or localhost)
ngsw-worker.jsexists indist/- Console for registration errors
- DevTools → Application → Service Workers
Fix:
# Ensure production build
ng build --configuration production
# Check ngsw-worker.js exists
ls dist/your-app/ngsw-worker.js
Updates Not Detected
Possible causes:
ngsw.jsonhash didn't change- Browser cache issues
- Service Worker stuck in old state
Fix:
// In DevTools → Application → Service Workers
// Click "Unregister" on old worker
// Refresh page → new worker registers
Dialog Shows Multiple Times
Problem: Creating multiple dialog instances
Fix:
private _promptUpdate(): void {
if (this._dialogOpen) return; // Guard clause
this._dialogOpen = true;
// ... rest of code
}
Results After Implementation
Before:
- 10-15 support tickets per deployment
- Users stuck on broken versions for hours
- Manual communication needed for critical updates
After:
- Zero cache-related support tickets
- 95% of users update within 30 minutes
- Smooth deployment experience
- Users control update timing
Advanced: Update Analytics
Track update adoption (optional):
private _promptUpdate(): void {
// ... dialog code
dialog.submitted.subscribe(() => {
// Track update accepted
this.analytics.track('sw_update_accepted');
this._reloadApp(dialog);
});
dialog.canceled.subscribe(() => {
// Track update dismissed
this.analytics.track('sw_update_dismissed');
this._closeDialog(dialog);
});
}
Key Takeaways
- Check updates smartly — hourly + on navigation, not every minute
- User controls timing — never force reload mid-work
- Disable in development — Service Worker fights hot reload
- Handle errors gracefully — network failures happen
- Test in production — Service Worker only works with production builds
Service Workers solve the cache problem elegantly. Users stay updated without losing work, deployments become stress-free, and support tickets disappear.
The best part? Once configured, it just works. Set it, deploy it, forget it.