CrackedRuby CrackedRuby

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