Skip to content

WS-2088: SPIKE: PWA offline article: implement SW caching mechanism#13755

Draft
elvinasv wants to merge 17 commits intolatestfrom
ws-2088-spike-pwa-offline-article-implement-sw-caching-mechanism
Draft

WS-2088: SPIKE: PWA offline article: implement SW caching mechanism#13755
elvinasv wants to merge 17 commits intolatestfrom
ws-2088-spike-pwa-offline-article-implement-sw-caching-mechanism

Conversation

@elvinasv
Copy link
Member

@elvinasv elvinasv commented Feb 25, 2026

Resolves JIRA: https://bbc.atlassian.net/browse/WS-2088
Screenshot 2026-03-03 at 08 15 44

How PWA offline article caching works

Overview

When a user installs the PWA, the service worker proactively caches a set of most-read articles per service, enabling offline reading. The feature is controlled by a per-service feature toggle (offlineArticle).

Caching flow

1. Offline page as data source
The offline page (/{service}/offline) is enriched server-side with a most-read-data script tag containing article metadata (URLs, timestamps). This makes the offline page the single source of truth for which articles to cache.

2. Periodic offline page refresh
On each PWA page load, the SW checks whether a refresh is due (default interval: 24h, controlled by REFRESH_INTERVAL_MS). If due, the offline page and its associated resources (scripts, stylesheets) are force-fetched and stored in the Cache API, replacing any stale version.

3. Article caching
After the offline page is refreshed, the SW parses the most-read-data from the cached HTML and for each article:

  • Compares the article's timestamp against what is stored in IndexedDB
  • If missing or outdated, fetches the article page and its resources into the cache
  • Stores metadata (url, timestamp, cachedAt) in IndexedDB (cachedArticles store)
  • Cleans up articles that are no longer in the most-read list and older than 72h (MAX_ARTICLE_AGE_MS)

4. Feature toggle gate
The offlineArticle toggle is evaluated client-side in useSendPWAStatus (reading from ToggleContext + ServiceContext for the per-service check). It is passed to the SW via the PWA_STATUS message as offlineArticle: { isEnabled, service } and persisted to IndexedDB (offlineArticleEnabled_{service}). Article caching is skipped entirely if the toggle is disabled. The offline page itself is always cached for PWA users regardless of the toggle.

5. Serving cached content during navigation
On a navigation fetch event, if the network request fails or returns 5xx:

  • The SW reads offlineArticleEnabled_{service} from IndexedDB (lazy — only on failure)
  • If enabled, checks the cache for the requested article URL and validates it is not older than MAX_ARTICLE_AGE_MS
  • If valid, serves the cached article
  • If the article is missing, stale, or the toggle is disabled — falls back to the cached offline page
  • If neither is available, returns a browser error response

6. Sync triggered on page load
The caching cycle is initiated on every PWA page load via useSendPWAStatusPWA_STATUS message → SW message handler. The REFRESH_INTERVAL_MS guard prevents unnecessary work on frequent visits.


Things to consider for follow-up

Page type filtering
Not all page types are suitable for offline caching. A predefined list of cacheable page types should be established, excluding:

  • Media asset pages (video/audio)
  • Podcast pages
  • Live/breaking news pages

This could be enforced either at the point of building the most-read list (server-side, filtering the data injected into the offline page) or in the SW before caching each article URL.

Periodic Background Sync
Currently the sync is tied to page load — if the user does not open the PWA for 24h+ the cached articles will be stale until the next visit. The Periodic Background Sync API would allow the SW to refresh articles in the background on a schedule, independent of page loads. This requires user permission and is currently only supported in Chromium-based browsers, so it should be treated as a progressive enhancement alongside the existing page-load-triggered sync.

@elvinasv elvinasv self-assigned this Feb 26, 2026
Comment on lines +67 to +74
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
const req = tx.objectStore(store).delete(key);
req.onsuccess = () => resolve();
req.onerror = e => reject(e.target.error);
});
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
const req = tx.objectStore(store).delete(key);
req.onsuccess = () => resolve();
req.onerror = e => reject(e.target.error);
});
};
const db = await openDB();
const tx = db.transaction(store, 'readwrite');
await tx.objectStore(store).delete(key);
};

Is there a reason why need all these promise wrappers around these db operations?

Comment on lines +196 to +202
const isMissing = !existing;
const isOutdated =
!!existing &&
article.timestamp &&
existing.timestamp !== article.timestamp;

if (!isMissing && !isOutdated) return;
Copy link
Contributor

@andrewscfc andrewscfc Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const isMissing = !existing;
const isOutdated =
!!existing &&
article.timestamp &&
existing.timestamp !== article.timestamp;
if (!isMissing && !isOutdated) return;
const isCached = existing;
const isOutdated =
!isCached &&
article.timestamp &&
existing.timestamp !== article.timestamp;
if (isCached && !isOutdated) return;

I found this very difficult to read so just suggesting a change to get away from the negative conditons

};

const cacheArticles = async service => {
const lastSync = await dbGet('meta', 'lastArticleSync');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO:

potentially we should save this per service, i.e. lastArticleSync_${service}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants