diff --git a/assets/offline/offline.html b/assets/offline/offline.html
new file mode 100644
index 000000000..b9c3f9cda
--- /dev/null
+++ b/assets/offline/offline.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+ Freefeed
+
+
+
+
+
+
+
+
Connection error
+
Your device may be offline or our servers may be experiencing problems.
+
+
+
+
+
diff --git a/assets/offline/service-worker.js b/assets/offline/service-worker.js
new file mode 100644
index 000000000..759b42df5
--- /dev/null
+++ b/assets/offline/service-worker.js
@@ -0,0 +1,73 @@
+const CACHE_NAME = 'offline';
+
+// Customize this with a different URL if needed.
+const OFFLINE_URL = `${location.origin}/offline.html`;
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ (async () => {
+
+ const cache = await caches.open(CACHE_NAME);
+
+ // Setting {cache: 'reload'} in the new request will ensure that the
+ // response isn't fulfilled from the HTTP cache; i.e., it will be from
+ // the network.
+ await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
+
+ })(),
+ );
+
+ // Force the waiting service worker to become the active service worker.
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ // Enable navigation preload if it's supported.
+ // See https://developers.google.com/web/updates/2017/02/navigation-preload
+ if ('navigationPreload' in self.registration) {
+ await self.registration.navigationPreload.enable();
+ }
+ })(),
+ );
+
+ // Tell the active service worker to take control of the page immediately.
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ // We only want to call event.respondWith() if this is a navigation request
+ // for an HTML page.
+ if (event.request.mode === 'navigate') {
+ event.respondWith(
+ (async () => {
+ try {
+ // First, try to use the navigation preload response if it's supported.
+ const preloadResponse = await event.preloadResponse;
+
+ if (preloadResponse) {
+ return preloadResponse;
+ }
+
+ // Always try the network first.
+ const networkResponse = await fetch(event.request);
+
+ return networkResponse;
+ } catch (error) {
+ // catch is only triggered if an exception is thrown, which is likely
+ // due to a network error.
+ // If fetch() returns a valid HTTP response with a response code in
+ // the 4xx or 5xx range, the catch() will NOT be called.
+ console.log('Fetch failed; returning offline page instead.', error);
+
+ const cache = await caches.open(CACHE_NAME);
+ const cachedResponse = await cache.match(OFFLINE_URL);
+
+ return cachedResponse;
+ }
+ })(),
+ );
+ }
+
+});
diff --git a/src/index.jsx b/src/index.jsx
index 52c0d42a9..dc4580514 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -372,6 +372,18 @@ ReactDOM.render(
document.querySelector('#app'),
);
+if ('serviceWorker' in navigator) {
+ navigator.serviceWorker
+ .register(`${location.origin}/service-worker.js`)
+ // eslint-disable-next-line promise/always-return
+ .then((reg) => {
+ console.log('Service worker registered.', reg);
+ })
+ .catch((err) => {
+ console.log(err);
+ });
+}
+
function checkPath(Component, checker) {
return (props) => {
return checker(props) ? : ;
diff --git a/webpack.config.babel.js b/webpack.config.babel.js
index 621777e00..511274455 100644
--- a/webpack.config.babel.js
+++ b/webpack.config.babel.js
@@ -65,6 +65,8 @@ const config = {
'assets/images/favicon.*',
'assets/images/ios/*.png',
'assets/ext-auth/auth-return.html',
+ 'assets/offline/offline.html',
+ 'assets/offline/service-worker.js',
opts.dev && { from: 'config.json', noErrorOnMissing: true },
]),
}),