Skip to content

Offline Fallback — Graceful Degradation When Network Fails

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Offline Fallback. We cover key concepts, practical examples, and best practices to help you master this topic.

Offline fallback patterns ensure your PWA displays meaningful content instead of errors when the network is unavailable, using cached pages and placeholder responses to maintain user trust.

What You'll Learn

By the end of this tutorial, you will understand how to implement offline fallbacks for navigation, images, API calls, and third-party content, creating a robust experience regardless of network state.

Why It Matters

Users do not care why the network failed. They care that your app is broken. A well-designed offline fallback keeps users engaged, shows that your app is thoughtfully built, and prevents the frustration of broken pages or infinite loading spinners.

Real-World Use

A recipe PWA shows a "You are offline — here are your recently viewed recipes" page when the network drops. Each recipe card is cached from previous visits. Instead of a generic error, the user sees useful content and can continue cooking.

Fallback Hierarchy

Offline Fallback Decision Tree
    Network request fails
         ↓
    ┌──────────────────────────────────────┐
    │  What type of request failed?        │
    └──────────────────────────────────────┘
         ↓            ↓            ↓
    Navigation     Image/Asset    API/Data
         ↓            ↓            ↓
    Cached HTML   Placeholder    Cached JSON
    page          image          response
         ↓            ↓            ↓
    Generic      Fallback      "Data may
    offline      icon SVG      be stale"
    page                       indicator

Think of offline fallbacks like a restaurant's contingency plan. If the kitchen runs out of beef, they offer chicken instead (alternative). If the whole kitchen is down, they offer free drinks and apologize (generic fallback). They never leave you standing at the counter with no response.

When a user navigates to a page that is not cached, serve a generic offline page:

self.addEventListener('fetch', event => {
    if (event.request.method !== 'GET') return;

    // Handle navigation requests
    if (event.request.mode === 'navigate') {
        event.respondWith(
            fetch(event.request)
                .catch(() => {
                    console.log('Navigation failed, serving offline page');
                    return caches.match('/offline.html');
                })
        );
        return;
    }

    // Handle other requests
    event.respondWith(
        fetch(event.request)
            .catch(() => {
                return caches.match(event.request);
            })
    );
});

Create the offline page:

<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>You Are Offline</title>
    <style>
        body {
            font-family: system-ui, sans-serif;
            text-align: center;
            padding: 2rem;
            background: #f8f9fa;
        }
        .offline-icon { font-size: 4rem; margin-bottom: 1rem; }
        h1 { color: #333; }
        p { color: #666; max-width: 400px; margin: 1rem auto; }
        .retry-btn {
            background: #3367D6;
            color: white;
            border: none;
            padding: 0.75rem 2rem;
            border-radius: 4px;
            cursor: pointer;
            font-size: 1rem;
        }
    </style>
</head>
<body>
    <div class="offline-icon">&#x26A0;</div>
    <h1>No Internet Connection</h1>
    <p>It looks like you are offline. Check your connection and try again.</p>
    <button class="retry-btn" onclick="window.location.reload()">
        Try Again
    </button>
</body>
</html>

Pre-cache the offline page during install:

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('offline-fallback-v1').then(cache => {
            return cache.addAll([
                '/offline.html',
                '/styles/offline.css'
            ]);
        }).then(() => self.skipWaiting())
    );
});

Image Fallback

When images fail to load offline, serve a placeholder:

self.addEventListener('fetch', event => {
    if (event.request.destination === 'image') {
        event.respondWith(
            fetch(event.request)
                .then(response => {
                    // Cache successful image responses
                    if (response.ok) {
                        const clone = response.clone();
                        caches.open('image-cache').then(cache => {
                            cache.put(event.request, clone);
                        });
                    }
                    return response;
                })
                .catch(() => {
                    return caches.match(event.request).then(cachedImage => {
                        if (cachedImage) {
                            console.log('Serving cached image:', event.request.url);
                            return cachedImage;
                        }
                        // Return inline SVG placeholder
                        console.log('Serving image placeholder');
                        return new Response(
                            `<svg xmlns="http://www.w3.org/2000/svg"
                                 width="300" height="200"
                                 viewBox="0 0 300 200">
                                <rect fill="#e0e0e0" width="300" height="200"/>
                                <text fill="#999" font-size="14"
                                      x="50%" y="50%" text-anchor="middle"
                                      dominant-baseline="middle">
                                    Image Offline
                                </text>
                             </svg>`,
                            { headers: { 'Content-Type': 'image/svg+xml' } }
                        );
                    });
                })
        );
    }
});

API Fallback with Stale Data Indicator

For API calls, serve cached data and indicate staleness:

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (url.pathname.startsWith('/api/')) {
        event.respondWith(
            fetch(event.request)
                .then(response => {
                    if (response.ok) {
                        const clone = response.clone();
                        caches.open('api-cache').then(cache => {
                            cache.put(event.request, clone);
                        });
                    }
                    return response;
                })
                .catch(() => {
                    return caches.match(event.request).then(cached => {
                        if (cached) {
                            // Add a header to indicate stale data
                            const headers = new Headers(cached.headers);
                            headers.append('X-Data-Stale', 'true');

                            return new Response(cached.body, {
                                status: cached.status,
                                statusText: cached.statusText,
                                headers: headers
                            });
                        }
                        return new Response(
                            JSON.stringify({ error: 'offline', message: 'Data unavailable offline' }),
                            {
                                status: 503,
                                headers: { 'Content-Type': 'application/json' }
                            }
                        );
                    });
                })
        );
    }
});

In your page JavaScript:

// Detect stale data in API responses
fetch('/api/posts')
    .then(response => {
        if (response.headers.get('X-Data-Stale') === 'true') {
            console.log('Showing cached data — may not be current');
            document.getElementById('stale-indicator').style.display = 'block';
        }
        return response.json();
    });

Third-Party Fallback

For third-party resources like analytics or CDN scripts, skip them gracefully:

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    // Third-party analytics — never block page load
    if (url.hostname === 'analytics.example.com' ||
        url.hostname === 'cdn.thirdparty.com') {
        event.respondWith(
            fetch(event.request)
                .catch(() => {
                    // Return empty response, do not show error
                    return new Response('', { status: 200 });
                })
        );
        return;
    }
});

Offline Analytics

Even offline, you can track user actions and send them when connectivity returns:

// Service worker
self.addEventListener('fetch', event => {
    if (event.request.url.includes('/analytics/')) {
        // Queue analytics events in IndexedDB instead of failing
        event.respondWith(
            fetch(event.request).catch(() => {
                // Store the event for later
                const eventData = {
                    url: event.request.url,
                    method: event.request.method,
                    timestamp: Date.now()
                };

                // Store in IndexedDB (pseudo-code)
                storeAnalyticsEvent(eventData);

                return new Response(JSON.stringify({ queued: true }), {
                    headers: { 'Content-Type': 'application/json' }
                });
            })
        );
    }
});

Common Mistakes

  1. Generic error messages. "Something went wrong" is unhelpful. Show a friendly, actionable message and suggest retrying or checking the connection.
  2. Not pre-Caching the offline page. If the offline page itself is not cached, the user sees the browser's default error page, not your branded fallback.
  3. Caching error responses. If fetch() returns a 500 error and you cache it, offline fallback serves the error instead of useful content.
  4. Showing stale data without indication. Users lose trust if they do not know data is cached. Always indicate when data may be stale.
  5. Blocking on third-party resources. If analytics goes down, your app should not break. Always provide fallbacks for non-critical third-party content.

Practice Questions

  1. What three types of fallback should you implement for a robust PWA?
  2. Why should you pre-cache the offline fallback page during install?
  3. How do you indicate to the user that displayed data is from cache and may be stale?
  4. What response should you return for failed third-party resource requests?
  5. How would you queue analytics events for delivery when connectivity returns?

Challenge: Implement a complete offline fallback system: a branded offline page for navigation, SVG placeholders for images, cached JSON with stale indicator for API calls, and graceful skip for third-party analytics. Test by going offline in DevTools.

FAQ

Does the offline page need to be pre-cached?

Yes. If the offline page is not cached, the service worker cannot serve it when offline. Always add the offline page to the pre-cache list during the install event.

Can I detect online/offline status in the service worker?

Service workers cannot directly access navigator.onLine. Use fetch failures as the signal for offline state instead.

Should I show different fallbacks for different page types?

Yes. A blog offline page might show recent posts from cache. A dashboard might show last-known stats. Tailor fallbacks to the page context.

How long should stale cached data be shown?

It depends on your content. News articles: a few hours. Reference docs: weeks. Use max-age headers or manual cache eviction to control staleness.

What if the user tries to submit a form offline?

Detect offline state in JavaScript and queue the form data. Use Background Sync to submit when connectivity returns. Show a clear message that the submission is pending.

Mini Project

Build a complete offline fallback system for a simple blog PWA. Create: 1) a branded offline.html page with retry button, 2) SVG image placeholders, 3) stale data indicator for article lists, and 4) graceful failure for a third-party weather widget. Pre-cache the offline page. Test by going offline in DevTools.

What's Next

Manual service workers are powerful but complex. Next, learn Workbox, Google's library that simplifies service worker generation with pre-built strategies and caching patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro