Hajime, the duck guy

Wednesday, March 19, 2025, by Hajime Yamasaki Vukelic

About this series

Step-by-step guide for building applications using nothing but browser APIs – no build, no tools, no frameworks

  1. Introduction to vanilla, goals, principles
  2. Requirements, architecture, content structure
  3. SVG spritesheet for icons
  4. Data molding, HTTP requests, basic event handling
  5. Storage, advanced event handling
  6. MPA, teamwork and shared code, separation of concerns
  7. Webfonts, asset preloading, responsive grid layouts
  8. Responsive tables using a grid layout with subgrids
  9. Toolless lazy loading and micro-frontends (part 1)
  10. Toolless lazy loading and micro-frontends (part 2)
  11. Reverting back to classic MPA and comparison with micro-frontend
  12. Code cleanup and vanilla CSS-only view transitions
  13. Accessible tabbed interface and a simple feature flag
  14. Visualization using vanilla SVG (part 1)
  15. Visualization using vanilla SVG (part 2)
  16. Visualization using vanilla SVG (part 3)
  17. PWA and offline mode

PWA and offline mode

In the previous part, I've wrapped up the initial work on the visualization and said I'd work on adding the offline mode. So here we go.

This part will cover two topics.

  1. Converting the app into a PWA (progressive web application).
  2. Adding offline mode.

Favicon

Well, perhaps you'd be wondering why I haven't added a favicon to the app before. I just figured there were more interesting things to talk about. With PWA in the mix, I do need an app icon, and this is a perfect opportunity to finally add a favicon.

The default favicon is, for historical reasons, a favicon.ico file. If one is provided by the server, the browser will use it regardless of whether you explicitly tell it to or not. Making a multi-resolution icon file that will work well across different browsers is easy these days. There are online tools that will do it for you. However, I also have another option: SVG. Since virtually all of my application graphics are SVG, it makes the workflow a bit simpler if I just use SVG for the favicon as well.

To add the icon to the app, I add the following to both index.html and place.html:

<head>
    <!-- .... -->

    <link rel="icon" href="favicon.svg">
    <!-- .... -->
</head>

That's it.

The favicon now appears in the browser tab:

Screenshot of the browser tab with the favicon
The icon is rounded due to the icon design, btw.

If you want to follow along, here's the file.

PWA conversion

To convert an app into a PWA, I need a manifest file. The one for this app looks like this:

{
  "name": "A Weather App",
  "short_name": "AWA",
  "description": "A simple weather app",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#fff",
  "theme_color": "#df0000",
  "icons": [
    {
      "src": "favicon.svg",
      "sizes": "any",
      "type": "image/svg+xml"
    },
    {
      "src": "favicon.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "screenshots": [
    {
      "src": "screenshot-wide.jpg",
      "sizes": "1920x1152",
      "type": "image/jpeg",
      "form_factor": "wide",
      "label": "Desktop version"
    },
    {
      "src": "screenshot-tall.jpg",
      "sizes": "644x1280",
      "type": "image/jpeg",
      "form_factor": "narrow",
      "label": "Mobile version"
    }
  ]
}

I'm not going to bore you with the details. You can read up about it on the MDN if you are curious. Alternatively, you can start with a blank manifest file, and then open the Chrome developer tools (the "Application" tab) and follow the warnings it displays:

Screenshot of the warnings in the developer
tools
WDD: Warning-driven development

I'll just mention that I've set the start_url configuration to ., so that I can host this app in a subdirectory of a site like I've done for this app. (GitHub pages is another case where I'd need to do this.)

Once everything is correctly done, and there are no more warnings, the option to install the app appears. This option appears in different places depending on the browser.

Screenshot of PWA install icon in Chrome
Icon to install a PWA in Chrome appears in the address bar.

The dialog to install the PWA is also different depending on the browser. Here's one example:

Screenshot of PWA install dialog in Vivaldi
The screenshot we specified is used in this dialog.

On mobile devices, this appears as "Add to home screen".

To cache the assets, I will add a service worker. I'll first add a file service-worker.js and kick it off with version string and a list of assets:

let VERSION = '1',
    CACHE_FILES = [
        '.',
        'index.html',
        'index.css',
        'index.js',
        'place.html',
        'place.css',
        'place.js',
        'common.css',
        'common.js',
        'Comfortaa-Regular.woff',
        'Comfortaa-REgular.woff2',
        'icons.svg',
        'favicon.svg',
        'favicon.png',
        'manifest.json',
    ]

The version doubles as the cache name. Each version will have a separate cache name so that I can invalidate stale caches.

When registered, service workers go through different stages. The global self object within the service worker is an event target that I can use to hook into these stages and initiate various actions.

The first stage is install, which is when the browser receives a request to register a new worker.

self.oninstall = ev => {
    ev.waitUntil(caches.open(VERSION).then(cache => cache.addAll(CACHE_FILES)))
}

The ev.waitUntil() call ensures that the service worker will live long enough to complete the async operation started by cache.addAll(CACHE_FILES). I create a cache with the same name as the version, and add the listed assets.

The next stage is activation:

self.onactivate = ev => {
    ev.waitUntil(caches.keys().then(cacheNames =>
        Promise.all(
            cacheNames
                .filter(cacheName => cacheName != VERSION)
                .map(cacheName => caches.delete(cacheName))
        ),
    ))
}

The caches except for the one that matches the current VERSION are deleted. This cleans up obsolete caches when the version number is bumped.

Lastly, I trap the fetch event to provide the cached result where available.

self.onfetch = ev => {
    ev.respondWith(caches.match(ev.request).then(response =>
        response || fetch(ev.request),
    ))
}

Once I reload the page, I can verify that caching is working by using the Chrome dev tools.

Screenshot of the cache view in the dev tools
The Cache storage view gives me a list of cached responses

The second way to see what the service worker is doing is to use the network tab. You should see a cog icon next to some of the assets now. If you look at the "Initiator" column, you'll see that those requests are initiated by the "service-worker.js" script. This tells you that the service worker is correctly intercepting the requests.

Screenshot of the cog icon in the dev tools
The cog icon appears before the name in the Network tab

Offline mode

The offline mode is activated when the device has no Internet connection. If the data exists, it will be reused with a warning that it may be stale (e.g., current weather). When the device goes online, and it's showing stale data, it automatically reloads.

We generally have two kind of error condition: actionable and non-actionable. Actionable errors are those user can do something about. Failure to connect to a remote server can either be actionable or non-actionable. If the Internet connection is down, then the user can check the Internet connection. If the server is down, then there's nothing the user can do. Either way, I need to be able to differentiate between these conditions in the application code.

Although caching in the service worker is convenient, it's not guaranteed to exist at runtime – it may take a while to install and activate. Because of this, I'm going to implement a simple LocalStorage-based caching.

First, I'm going to add the caching mechanism to the getWeatherData():

let /* .... */
    getWeatherData = (handleError, handleSuccess) => {
        if (!cachedData) {
            let /* .... */
                cacheKey = `${selectedPlace.lat}:${selectedPlace.long}`,
                putCachedData = data => {
                    localStorage.forecastData = JSON.stringify({...data, cacheKey, cachedAt: Date.now()})
                },
                getCachedData = () => {
                    let data = JSON.parse(localStorage.forecastData || 'null')
                    if (!data || data.cacheKey != cacheKey) throw Error('bad response')
                    return data
                }

            // ....
        }
        // ....
    }

In one of the past parts, I insisted that the only good reasons to factor something out are code reuse and clarity. The putCachedData() falls under 'clarity'. By bringing the single line closer to getCachedData() I make it easier to observe the stored structure and determine whether getCachedData() is retrieving it correctly. This is always a good idea, but it becomes critical when working on large files.

Before I can use the cached data, I need to determine whether it matches the place selected by the user. For this I need to create a cache key based on that information, store that in the cache, and compare later when retrieving it. I'm using the coordinates as the cache key as a proxy for uniquely identifying a place. Though convenient, I don't use the URL as the key because it includes the date, and that would cause the cache to automatically expire as soon as the date changes – not my intent.

When actually fetching the data, I first check if the browser is offline, and hit the cache if it is. When the browser is online, I proceed to fetch().

As it turns out, browsers don't necessarily report when the host OS is offline. I'm not 100% sure how it works, but toggling network connectivity simply doesn't reflect in in the navigator.onLine property. As a fallback, I'm hitting the cache when fetch() throws an exception. In a vast majority of cases, fetch() throws when the server is unreachable. This could technically mean the service rather than Internet outage, but I can't be too picky as the alternative would mean pinging a random known host online on interval, which would be wasteful.

let /* .... */
    getWeatherData = (handleError, handleSuccess) => {
        if (!cachedData) {
            // ....
            
            if (!navigator.onLine) cachedData = Promise.resolve().then(getCachedData)
            else cachedData = fetch(url)
                .then(
                    res => {
                        if (!res.ok) throw Error('bad response')
                        return res.json().then(data => {
                            putCachedData(data)
                            return data
                        })
                    },
                    () => getCachedData(),
                )
        }

        // ....
    }

To test whether the cache works, I can toggle the offline mode from the Chrome dev tools' Application tab, under Service Worker.

Screenshot of the developer tools offline mode option
When this mode is turned on, all network requests are blocked

I need a way to indicate that stale data is shown. To keep things simple, I'll add a banner at the bottom that shows this information while the app is offline.

As always, I'm staring with the markup. It looks like this:

<main>
    <!-- .... -->

    <x-network-condition>
        <p aria-live="polite"></p>

        <template data-name="disconnected">
            <x-icon name="disconnected"></x-icon>
            <span>
                You are currently viewing cached data retrieved <span class="cache-time"></span>
            </span>
        </template>
    </x-network-condition>
</main>

The aria-live="polite" on the paragraph element marks the element as a live region. If the user uses a screen reader, any content that is added to this element will be announced almost immediately.

For styling, it's the usual: little toast that appears at the bottom of the page. I also need to make room for the content at the bottom of the page, so that the toast banner doesn't obscure the last row of the forecast table.

main {
    /* .... */
    padding-bottom: 6em;
}

x-network-condition p {
    display: flex;
    gap: 0.5em;
    align-items: center;
    padding: 1em;

    border-radius: 0.2em;
    background: var(--alert-color);
    color: var(--secondary-text-color);
    box-shadow: 0 0.5em 1em rgba(0, 0, 0, 0.2);
    line-height: 1.4;
    transition: transform 0.5s;
}

x-network-condition p:empty {
    transition: none;
    transform: translateY(calc(100% + 2em));
}

I'm using the :empty selector to move the paragraph out of sight when it's... well, empty. This saves me the trouble of performing additional DOM manipulation after I set the content.

The reason I add transition: none, is just laziness. It's a quick and dirty hack to avoid a weird situation where the content is removed first and then translated downwards. It looks janky and not cool at all. Completely disabling transition makes it go away immediately. This doesn't prevent the animation in the other direction.

Btw, in case you're wondering, you can already test this CSS-implemented behavior by manually manipulating the <p aria-live="polite"> element either in the JavaScript console or directly in the Elements tab of the developer tools. To make it not empty, simply add some content to the element. You should see the element pop into the view. Like this:

document.querySelector('x-network-condition p').textContent = 'Test'

It's not necessary to wait until your JavaScript is all done before testing this.

This is one of the reasons I think CSS-in-JS, atomic CSS, and similar inline or class-based CSS solutions are counter-productive: they just slow things down – contrary to common belief – by requiring you to have more fully implemented solutions before you can check whether your CSS does what you want. (Well, slow you down if you know what fast looks like, I guess.) It also doesn't help that some of these solutions don't let you use useful shortcuts like :empty to save you some JavaScript to begin with.

Speaking of JavaScript, the implementation of the <x-network-condition> element looks like this:

customElements.define('x-network-condition', class extends CustomElement {
    setup() {
        let message = this.querySelector('p'),
            networkWarningMessageTemplate = this.querySelector('[data-name=disconnected]')

        // ....

        let displayNetworkConditionWarning = data => {
            if (!data.cachedAt) message.replaceChildren()
            else requestAnimationFrame(() => {
                let cacheTime = formatRelativeTime(new Date(data.cachedAt)),
                    messageContent = networkWarningMessageTemplate.content.cloneNode(true)
                messageContent.querySelector('.cache-time').textContent = cacheTime
                message.replaceChildren(messageContent)
            })
        }

        if (selectedPlace)
            getWeatherData(() => {}, displayNetworkConditionWarning)
    }
});

The error handler in the getWeatherData() call is a blank function that doesn't do anything as I'm not interested in that condition. Errors are already indicated elsewhere. I'd like to point out that it's possible to manipulate the cloned template content before inserting into the DOM. This ensures that the message is inserted in one go, reducing the possibility of confusion on the part of the screen readers.

When the data isn't cached, the code will clear the contents of the message container. This is going to be useful when handling online-offline toggle later.

Earlier, I omitted a portion of the code related to formatting the interval to minimize distraction. Here it is:

customElements.define('x-network-condition', class extends CustomElement {
    setup() {
        // ....
        
        let dayInSeconds = 24 * 60 * 60,
            hourInSeconds = 60 * 60,
            relativeFormat = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }),
            formatRelativeTime = date => {
                let secondsAgo = Math.round((date - new Date()) / 1000)

                if (secondsAgo >= dayInSeconds)
                    return relativeFormat.format(Math.floor(secondsAgo / dayInSeconds), 'day')
                if (secondsAgo >= hourInSeconds)
                    return relativeFormat.format(Math.floor(secondsAgo / hourInSeconds), 'hour')
                return 'less than an hour ago'
            }
    
        // ....
    }
})

The formatRelativeTime() uses the Intl API to format values like (-2, 'day') to 2 days ago. In this particular case, it's unnecessary to handle longer time spans like weeks, months, or years, as it's not very likely that the user would go that long without Internet. Maybe a few days off-grid while camping or something. Similarly, there's no need to handle periods shorter than an hour.

This kind of function exists in many date/time-related libraries. Why would I write my own then? The reasons are straightforward:

  1. I don't need all the other features present in such libraries.
  2. I don't even need all the features that such functions have. For instance, I don't need cases like 2 hours and 4 minutes or 2 weeks ago. I'd be pulling in cases that are unnecessary for my function, causing bloat.
  3. (For me) it's a relatively low-effort affair. It's also the kind of function AI will very likely get right in one go even if I don't necessarily feel like writing it by hand.

When I disconnect my machine from the Internet, here's what I get:

Screenshot of the offline message
The message slides in from the bottom of the screen when the cache data is fetched

Getting back online

It would be good if the message would clear on its own when the machine goes back online. So I'm doing that next.

When the browser goes online or offline, the online or offline event, respectively, fires on the window object. Another thing I know is that all parts of the application will invoke the same getWeatherData() function. Put the two together, and I get that the best place for the event handler is in getWeatherData(). That would add this feature to all pieces of code that invoke this function.

let /* .... */
    getWeatherData = (handleError, handleSuccess) => {
        // ....

        window.addEventListener(navigator.onLine ? 'offline' : 'online', () => {
            getWeatherData(handleError, handleSuccess)
        }, {once: true})

        return cachedData.then(handleSuccess, handleError)
    }

Whenever the getWeatherData() function is called, a recursive call is scheduled to execute the next time the network connection state changes. I use the {once: true} option to prevent duplicate execution, and the navigator.onLine ? 'offline' : 'online' ensures that I alternate between the network states when the state changes.

This modification by itself doesn't quite work. Why? Because the data is cached. Simply calling getWeatherData() will actually not do anything. To fix this, I'm adding a separate set of online and offline event listeners globally that will invalidate the cache. Because the event listeners are called in the order in which they are added, I must ensure that this one is added before any code has had the chance to call getWeatherData(). The best place for this is right after the getWeatherData() declaration.

let /* .... */
    getWeatherData = () => {
        // .....
    }

window.addEventListener('online', () => cachedData = null)
window.addEventListener('offline', () => cachedData = null)

Incidentally, this design is possible because getWeatherData() uses the continuation-passing style (CPS) – it takes callbacks that handle the continuation of an async operation, rather than returning a promise or some other mechanism. Thanks to this design, the function retains control over when the callbacks are invoked, allowing me to invoke them multiple times in response to connection state changes.

As before, I can use the "Offline" toggle in the dev tools (under the Application tab, Service workers section), to test whether this mechanism is working correctly.

Also as before, this may or may not actually work in real life due to flaky OS/browser support. Why am I leaving it in then? Because it's nice UX enhancement for those fortunate enough to be on a good platform-browser combination, and it doesn't break essential features or degrade the UX when it doesn't work – a.k.a. progressive enhancement.

Conclusion

In this part, I've covered the PWA conversion and the offline mode. I've also shown you how to use the Intl API to do some time interval formatting.

In the next part, I'm going to work on making the weather summary more interactive by allowing the user to organize the tiles and change the data being shown.

Let me briefly touch on some payload stats across two pages:

  • HTML: 8k
  • CSS: 15.5k
  • JavaScript: 22.7k

As a reminder, this is all unminified code, complete with comments and whitespace. I'm just throwing the numbers out there. You decide what they mean. 😉

As usual, the live version is online.

Posted in Programming tips
Back to top