Overview
Service Workers are JavaScript files that run in a separate thread from the main browser thread, acting as programmable network proxies between web applications and the network. They intercept network requests, modify responses, and manage cached resources without blocking the main execution thread. Service Workers enable offline functionality, background synchronization, push notifications, and advanced caching strategies for progressive web applications.
The Service Worker specification originated from the need to replace the problematic AppCache API, which had rigid caching rules and unpredictable behavior. Service Workers provide programmatic control over caching and network requests through a JavaScript API, allowing developers to define custom strategies for different types of resources.
A Service Worker operates independently of the web page that registered it. After registration and installation, the Service Worker persists and can control multiple pages within its scope, even when those pages are closed. This persistent nature distinguishes Service Workers from regular JavaScript that terminates when the page unloads.
// Basic Service Worker registration from a web page
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered:', registration.scope);
})
.catch(error => {
console.error('Registration failed:', error);
});
}
Service Workers require HTTPS in production environments due to their ability to intercept and modify network requests. This security requirement prevents man-in-the-middle attacks that could inject malicious code. Local development on localhost is exempt from this requirement.
Key Principles
Service Workers operate through a defined lifecycle consisting of registration, installation, activation, and idle states. The browser manages this lifecycle, and understanding state transitions is essential for correct Service Worker implementation.
Registration occurs when the web page calls navigator.serviceWorker.register(). The browser downloads the Service Worker file and begins the installation process if the file is new or has changed. The browser performs a byte-by-byte comparison to detect changes, meaning even whitespace modifications trigger updates.
Installation happens when the Service Worker first downloads or when an updated version is detected. During installation, the Service Worker typically precaches critical resources needed for offline functionality. The installation phase emits an install event that the Service Worker can handle:
// Service Worker installation handler
self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
]);
})
);
});
Activation occurs after installation completes and no pages are using the old Service Worker version. The activation phase handles cleanup tasks like deleting old caches. A newly installed Service Worker waits in a standby state until all pages using the old version close, unless skipWaiting() is called.
// Activation handler cleaning old caches
self.addEventListener('activate', event => {
const cacheWhitelist = ['v2'];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
Fetch interception forms the core functionality where Service Workers act as network proxies. The fetch event fires for every network request within the Service Worker's scope, allowing custom response strategies:
// Basic fetch handler
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
Scope defines which pages the Service Worker controls. By default, the scope is the directory containing the Service Worker file and all subdirectories. A Service Worker at /js/sw.js controls only pages under /js/, while one at the root controls all pages. The scope can be narrowed but not widened beyond the Service Worker's location.
Communication between Service Workers and pages happens through the postMessage API since they run in separate threads. Pages send messages to Service Workers using serviceWorker.controller.postMessage(), while Service Workers send messages to clients using client.postMessage().
Service Workers terminate when idle to conserve resources. The browser may shut down an inactive Service Worker at any time, and events reactivate it as needed. This means Service Workers cannot maintain state in global variables between events.
Practical Examples
Cache-First Strategy prioritizes cached content, falling back to the network when resources are not cached. This strategy works well for static assets that rarely change:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(networkResponse => {
return caches.open('dynamic-v1').then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
});
Network-First Strategy attempts to fetch from the network, falling back to cache when the network is unavailable. This strategy suits dynamic content that should be as current as possible:
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(networkResponse => {
return caches.open('dynamic-v1').then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
})
.catch(() => {
return caches.match(event.request);
})
);
});
Stale-While-Revalidate Strategy returns cached content immediately while fetching updated content in the background. This provides fast responses while keeping content fresh:
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('dynamic-v1').then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return cachedResponse || fetchPromise;
});
})
);
});
Offline Page Pattern displays a custom offline page when the network is unavailable and no cached content exists:
const OFFLINE_PAGE = '/offline.html';
self.addEventListener('install', event => {
event.waitUntil(
caches.open('offline').then(cache => {
return cache.add(OFFLINE_PAGE);
})
);
});
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_PAGE);
})
);
}
});
Background Sync queues failed requests and retries them when connectivity returns. This requires registering a sync event and handling it in the Service Worker:
// In the Service Worker
self.addEventListener('sync', event => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const db = await openDatabase();
const messages = await db.getAll('outbox');
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
});
await db.delete('outbox', message.id);
} catch (error) {
// Will retry on next sync
console.error('Sync failed:', error);
}
}
}
Implementation Approaches
Cache Strategy Selection depends on resource characteristics and application requirements. Static assets like CSS, JavaScript, and images benefit from cache-first strategies since they change infrequently. Dynamic content like API responses requires network-first or stale-while-revalidate approaches to maintain freshness.
Route-Based Strategies apply different caching strategies based on request patterns. This approach examines the URL or request type to determine the appropriate strategy:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// Network-first for API calls
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.(css|js|png|jpg)$/)) {
// Cache-first for static assets
event.respondWith(cacheFirst(event.request));
} else if (event.request.mode === 'navigate') {
// Stale-while-revalidate for HTML pages
event.respondWith(staleWhileRevalidate(event.request));
} else {
event.respondWith(fetch(event.request));
}
});
Cache Versioning manages cache updates and cleanup. A version identifier in the cache name facilitates controlled updates and prevents stale content:
const CACHE_VERSION = 'v3';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith('app-cache-') && name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});
Workbox Library provides a higher-level abstraction over Service Worker APIs. Workbox simplifies common patterns and reduces boilerplate code while offering advanced features like cache expiration and routing:
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.0/workbox-sw.js');
workbox.routing.registerRoute(
({request}) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
);
Precaching Critical Resources during installation ensures offline functionality for essential assets. This approach downloads and caches files before the Service Worker activates:
const PRECACHE_URLS = [
'/',
'/styles/critical.css',
'/scripts/app.js',
'/offline.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(PRECACHE_URLS);
}).then(() => {
return self.skipWaiting();
})
);
});
Progressive Enhancement treats Service Workers as an enhancement rather than a requirement. The application functions without a Service Worker but gains offline capabilities when one is available. This approach requires careful feature detection and graceful degradation.
Security Implications
Service Workers require HTTPS because they intercept all network traffic for their scope. An attacker controlling a Service Worker could inject malicious code, steal credentials, or modify sensitive data. HTTPS prevents man-in-the-middle attacks during Service Worker registration and updates.
Same-Origin Policy restricts Service Workers to the same origin as the registering page. A Service Worker registered from https://example.com cannot intercept requests to https://different.com. This prevents cross-origin attacks where malicious sites attempt to control other origins.
Scope Limitations prevent Service Workers from controlling pages outside their scope. A Service Worker cannot widen its scope beyond its file location through configuration. This restriction prevents a compromised subdirectory from affecting the entire site:
// This registration attempt will fail if the Service Worker
// file is at /app/sw.js but tries to control /
navigator.serviceWorker.register('/app/sw.js', {
scope: '/' // Invalid - wider than Service Worker location
});
Content Security Policy headers affect Service Worker behavior. CSP directives like worker-src and script-src control which Service Workers can register. A restrictive CSP might block Service Worker registration entirely:
# In a Rails controller
response.headers['Content-Security-Policy'] = "worker-src 'self'"
Cache Poisoning occurs when an attacker injects malicious content into the cache. Service Workers should validate cached responses and implement cache expiration policies. Never cache authenticated requests or sensitive data without additional protections.
Update Vulnerabilities arise when Service Workers fail to update properly. The browser checks for updates every 24 hours at most, but critical security fixes need faster deployment. Force updates by changing the Service Worker file or using registration.update():
// Check for updates periodically
setInterval(() => {
navigator.serviceWorker.getRegistration().then(registration => {
if (registration) {
registration.update();
}
});
}, 60 * 60 * 1000); // Check hourly
Cross-Site Scripting Protection requires careful handling of cached content. Service Workers should not cache pages with user-generated content without proper sanitization. Implement strict cache policies for dynamic content:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Never cache pages with user content
if (url.pathname.startsWith('/user/')) {
event.respondWith(fetch(event.request));
return;
}
// Apply caching strategy for safe content
event.respondWith(cacheFirst(event.request));
});
Authentication Token Exposure represents a risk when Service Workers cache authenticated requests. Store tokens in memory rather than cache, and exclude authenticated endpoints from caching strategies. Implement token refresh mechanisms that work offline.
Common Pitfalls
Update Delays occur when old Service Workers continue running after deployment. Browsers wait for all pages using the old Service Worker to close before activating the new version. This causes users to see outdated content until they close all tabs. Call skipWaiting() in the install event and clients.claim() in the activate event to force immediate updates:
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(clients.claim());
});
Infinite Installation Loops happen when the Service Worker file changes on every load. Build tools that inject timestamps or random values into the Service Worker file cause constant reinstallation. Service Workers should have stable URLs and content:
# Bad - changes every time
class ServiceWorkerController < ApplicationController
def show
render plain: "// Generated at #{Time.now}\nself.addEventListener('install', ...)"
end
end
# Good - static file with version
class ServiceWorkerController < ApplicationController
def show
send_file Rails.root.join('public', 'service-worker.js')
end
end
Broken Offline Functionality results from incomplete precaching. Service Workers must cache all dependencies for offline pages, including CSS, JavaScript, images, and fonts. A single missing resource breaks offline functionality:
// Incomplete - missing dependencies
const PRECACHE_URLS = ['/offline.html'];
// Complete - includes all dependencies
const PRECACHE_URLS = [
'/offline.html',
'/styles/offline.css',
'/images/offline-icon.png',
'/fonts/roboto.woff2'
];
Opaque Response Caching causes cache quota issues. Opaque responses from cross-origin requests without CORS consume significantly more cache space than transparent responses. The browser pads opaque responses to prevent timing attacks, often reserving 7MB per response regardless of actual size:
// Avoid caching opaque responses
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
if (response.type === 'opaque') {
// Don't cache opaque responses
return response;
}
return caches.open('dynamic').then(cache => {
cache.put(event.request, response.clone());
return response;
});
})
);
});
State Management Problems arise because Service Workers terminate when idle. Global variables reset between events. Use Cache API, IndexedDB, or other persistent storage for state that must survive termination:
// Wrong - state lost when Service Worker terminates
let messageQueue = [];
self.addEventListener('sync', event => {
// messageQueue will be empty
processQueue(messageQueue);
});
// Correct - persistent storage
self.addEventListener('sync', event => {
event.waitUntil(
getQueueFromIndexedDB().then(queue => {
return processQueue(queue);
})
);
});
Navigation Preload Issues occur when enabling navigation preload without proper handling. Navigation preload starts network requests before the Service Worker activates, but the Service Worker must check for preloaded responses:
self.addEventListener('activate', event => {
event.waitUntil(self.registration.navigationPreload.enable());
});
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
event.preloadResponse.then(preloadResponse => {
return preloadResponse || fetch(event.request);
})
);
}
});
Cache-Control Header Conflicts happen when Service Workers cache resources with restrictive cache headers. The Service Worker Cache API ignores HTTP cache headers, but this can create confusion when debugging caching behavior. Document which resources the Service Worker caches regardless of headers.
Ruby Implementation
Ruby web applications serve Service Worker files and provide APIs that Service Workers interact with. The Service Worker file itself contains JavaScript, but Ruby handles serving, asset compilation, and backend endpoints.
Serving Service Worker Files requires specific routing and headers. Rails applications typically place Service Worker files in the public directory or serve them through controllers:
# config/routes.rb
Rails.application.routes.draw do
get '/service-worker.js', to: 'service_workers#show', defaults: { format: 'js' }
end
# app/controllers/service_workers_controller.rb
class ServiceWorkersController < ApplicationController
def show
expires_in 5.minutes, public: true
render file: Rails.root.join('app', 'javascript', 'service-worker.js'),
content_type: 'application/javascript'
end
end
Asset Pipeline Integration in Rails requires careful configuration. Service Workers should not be fingerprinted since they must have stable URLs for registration:
# config/initializers/assets.rb
Rails.application.config.assets.configure do |env|
# Don't fingerprint Service Worker
env.register_precompile_check(proc { |path|
!path.end_with?('service-worker.js')
})
end
# Exclude from asset pipeline
Rails.application.config.assets.precompile += ['service-worker.js']
Webpacker Configuration for modern Rails applications requires special handling for Service Workers:
# config/webpack/environment.js
const { environment } = require('@rails/webpacker')
environment.config.merge({
entry: {
'service-worker': './app/javascript/service-worker.js'
},
output: {
filename: '[name].js' // No hashing for Service Worker
}
})
module.exports = environment
API Endpoint Design for Service Workers should support offline-first patterns. Implement versioned endpoints that Service Workers can cache reliably:
# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
def index
articles = Article.published.includes(:author)
# Set cache headers for Service Worker
expires_in 5.minutes, public: true
render json: articles, each_serializer: ArticleSerializer
end
def show
article = Article.find(params[:id])
# Longer cache for individual articles
expires_in 1.hour, public: true
render json: article, serializer: ArticleSerializer
end
end
Background Job Integration coordinates with Service Worker background sync. When Service Workers sync data, the backend needs to handle potentially stale or conflicting updates:
class SyncMessageJob < ApplicationJob
queue_as :default
def perform(message_params, sync_token)
# Check if this message was already synced
return if Message.exists?(sync_token: sync_token)
Message.create!(
message_params.merge(
sync_token: sync_token,
synced_at: Time.current
)
)
rescue ActiveRecord::RecordInvalid => e
# Handle conflicts from duplicate syncs
Rails.logger.warn("Sync conflict: #{e.message}")
end
end
Push Notification Backend requires endpoints for subscribing clients and sending notifications:
# app/models/push_subscription.rb
class PushSubscription < ApplicationRecord
validates :endpoint, presence: true, uniqueness: true
def send_notification(payload)
message = JSON.generate(payload)
Webpush.payload_send(
message: message,
endpoint: endpoint,
p256dh: p256dh,
auth: auth,
vapid: {
subject: 'mailto:admin@example.com',
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
}
)
end
end
# app/controllers/push_subscriptions_controller.rb
class PushSubscriptionsController < ApplicationController
def create
subscription = PushSubscription.create!(subscription_params)
render json: { id: subscription.id }, status: :created
end
private
def subscription_params
params.require(:subscription).permit(:endpoint, :p256dh, :auth)
end
end
Manifest File Generation creates Web App Manifests that work with Service Workers:
# app/controllers/manifest_controller.rb
class ManifestController < ApplicationController
def show
manifest = {
name: 'My App',
short_name: 'App',
start_url: root_path,
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: asset_path('icon-192.png'),
sizes: '192x192',
type: 'image/png'
},
{
src: asset_path('icon-512.png'),
sizes: '512x512',
type: 'image/png'
}
]
}
render json: manifest
end
end
Reference
Service Worker Lifecycle Events
| Event | When Fired | Purpose |
|---|---|---|
| install | Service Worker first downloads or new version detected | Precache resources, prepare Service Worker |
| activate | After installation and old version closed | Clean up old caches, migrate data |
| fetch | Network request within scope | Intercept and respond to requests |
| sync | Background sync triggered or connectivity restored | Process queued operations |
| push | Push notification received | Display notification to user |
| message | postMessage called from page or another worker | Handle communication between contexts |
| notificationclick | User clicks notification | Open page or perform action |
Cache API Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| caches.open | cacheName | Promise Cache | Opens cache with given name, creates if missing |
| caches.match | request, options | Promise Response | Searches all caches for matching request |
| caches.has | cacheName | Promise boolean | Checks if cache exists |
| caches.delete | cacheName | Promise boolean | Deletes cache and all entries |
| caches.keys | none | Promise string array | Returns array of cache names |
| cache.put | request, response | Promise void | Stores request/response pair |
| cache.add | request | Promise void | Fetches and caches request |
| cache.addAll | requests | Promise void | Fetches and caches multiple requests |
| cache.match | request, options | Promise Response | Finds matching cached response |
| cache.matchAll | request, options | Promise Response array | Finds all matching cached responses |
| cache.delete | request, options | Promise boolean | Removes cached response |
| cache.keys | request, options | Promise Request array | Returns cached request objects |
Registration Options
| Option | Type | Default | Description |
|---|---|---|---|
| scope | string | Service Worker directory | Path Service Worker controls |
| type | string | classic | Module type: classic or module |
| updateViaCache | string | imports | Cache behavior for updates: imports, all, none |
Caching Strategies Comparison
| Strategy | Network Speed | Cache Speed | Data Freshness | Offline Support | Use Case |
|---|---|---|---|---|---|
| Cache First | Slow | Fast | Stale | Full | Static assets, images |
| Network First | Fast | Fallback | Fresh | Partial | API calls, dynamic content |
| Cache Only | N/A | Fast | Stale | Full | Precached offline pages |
| Network Only | Fast | N/A | Fresh | None | Real-time data, tracking |
| Stale While Revalidate | Fast | Fast | Eventually fresh | Full | HTML pages, frequent updates |
FetchEvent Properties
| Property | Type | Description |
|---|---|---|
| request | Request | Request object being intercepted |
| clientId | string | ID of client making request |
| resultingClientId | string | ID of client that will use response |
| replacesClientId | string | ID of client being replaced by navigation |
| preloadResponse | Promise Response | Response from navigation preload if enabled |
Response Types
| Type | CORS | Caching | Origin | Use Case |
|---|---|---|---|---|
| basic | N/A | Full access | Same-origin | Internal resources |
| cors | Yes | Full access | Cross-origin | API calls with CORS |
| opaque | No | Limited | Cross-origin | Images, scripts without CORS |
| opaqueredirect | No | None | Cross-origin | Redirects without CORS |
Client Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| clients.get | id | Promise Client | Gets client by ID |
| clients.matchAll | options | Promise Client array | Gets all clients matching options |
| clients.openWindow | url | Promise WindowClient | Opens new browser window |
| clients.claim | none | Promise void | Takes control of all clients immediately |
| client.postMessage | message, transfer | void | Sends message to client |
| client.url | none | string | Client URL |
| client.type | none | string | Client type: window, worker, sharedworker |
| client.id | none | string | Unique client identifier |