🇵🇸 Donate eSIMs to Gaza 🇵🇸

Light/Dark mode and the bfcache

A watercolor painting of a carriage being pulled by mysterious animals. The carriage and the surrounding landscape are painted as black silhouettes on the top half of the image, with a black sun above. Then, on the bottom half of the image their mirror reflection is painted in white against a black backround, with a red sun below.
Solving a tricky problem with bfcache that I ran into when building my site's Settings page.
(Art by Kim Diaz Holm)

I had meant to write about the bfcache a while back, when I was first bitten by it, but I didn’t get around to it.

It came up in conversation with Zephyr last night, so figured I’d write about it now!

Light-Dark Mode

The way this website handles Light/Dark mode in the /settings page is a little complicated.

In my CSS, I use the light-dark() function to change the website’s colors based on the user’s operating system settings.

This bit is really simple:

:root {
    color-scheme: light dark;
}

body {
    background-color: light-dark(#fbecb2, #1f1f1f);
}

Then, when the user changes the dark/light mode on their device (or when the device automatically changes it for them at sunset), the background color automatically changes.

You can give it a try right now, change your device’s color mode, and watch this website change colors!

For more details on how light-dark() works, check out this fantastic article by Sara Joy.

This all works pretty well (though I did have issues with older versions of Safari), and is probably what I’d recommend for most people. I decided to be extra though, and allow people the ability to override their OS settings.

If you go to my /settings page, you’ll see that you can choose between “Auto”, “Light”, and “Dark”. “Auto”, the default, respects the OS settings, but if you select “Light” or “Dark” you’ll force the website into that color mode.

A screenshot of my settings page, with the Color mode picker as described above, and also another box labeled 'Contrast mode', which lets you choose between Auto, Lower, and Higher.
The same is true for my contrast mode setting.

This means that, for example, even if your computer is in light mode, you can choose that my website should be shown in dark mode.

To support this, and to persist the settings across viewings, I had to use some Javascript.

Persisting Settings

I use local storage to persist the selected color mode setting.

When you click one of the radio buttons, an event listener writes the setting to local storage, sets the color-scheme on the documen appropriately, and sets a custom CSS variable with my favicon URL to allow that to change colors as well.

Here’s the JS and HTML that I’m using:

function setColorAuto() {
    document.querySelector(':root').style.colorScheme = 'light dark';
    document.querySelector(':root').style.removeProperty('--favicon-url');
    localStorage.setItem("color", "auto");
}

function setColorLight() {
    document.querySelector(':root').style.colorScheme = 'light';
    document.querySelector(':root').style.setProperty('--favicon-url', "url('/images/favicon.svg')");
    localStorage.setItem("color", "light");
}

function setColorDark() {
    document.querySelector(':root').style.colorScheme = 'dark';
    document.querySelector(':root').style.setProperty('--favicon-url', "url('/images/favicon_white.svg')");

    localStorage.setItem("color", "dark");
}
<fieldset>
    <legend>Color mode:</legend>
    <div>
        <input onchange="setColorAuto()" type="radio" id="colorAuto" name="color" value="auto" />
        <label for="colorAuto">Auto</label>
    </div>
    <div>
        <input onchange="setColorLight()" type="radio" id="colorLight" name="color" value="light" />
        <label for="colorLight">Light</label>
    </div>
    <div>
        <input onchange="setColorDark()" type="radio" id="colorDark" name="color" value="dark" />
        <label for="colorDark">Dark</label>
    </div>
</fieldset>

Then I have a final piece of Javascript that runs on every page on this website, which reads the local storage, and calls one of the above setColor... functions as appropriate:

function loadFromLocalStorage() {
    const color = localStorage.getItem("color");

    switch (color) {
        case 'light':
            setColorLight();
            break;
        case 'dark':
            setColorDark();
            break;
        default:
            setColorAuto();
    }

    // more code for the contrast setting here...
}

(All code heavily inspired by Sara Joy’s article)

And bam! Now you can override the default system color theme, and the website will remember your choice when you next visit.

But there was one problem: if you were on my a page on my website (in Chrome or Firefox), and then went to /settings, changed your color mode, and then hit the back button in your browser – it wouldn’t work! The page you went back to would still be on the old theme.

Fortunately there was an easy fix, but I had to learn about a new browser feature to figure it out.

The bfcache

The Back/Forward cache (bfcache) is a new feature of modern web browsers that aims to enable instant back and forward navigation.

Before bfcache, when you hit back or forward, the browser would tear down the page you were currently on, deleting any Javascript state, saved images, etc, and load the new page you’re going to.

Given you’re going backwards or forwards to a page you’ve already been on, the resulting load would hopefully be quick, with resources (e.g. images) stored in the browser’s cache, meaning there shouldn’t need to be another round trip across the network.

Unfortunately, a whole lot of the web is built using bloated Javascript, which can take a long time to re-run, even if there’s no network requests. Additionally, increasing amounts of state are being stored client-side, which can be erased on page reload.

So the browser people made the bfcache, which stores the entire state of a page, Javascript heap and all, and uses it to recreate pages on back/forward navigation.

This web.dev article goes into depth about how it works.

Loading a page from the bfcache is pretty much instant, which is nice, but, by design, it means Javascript that was run on initial page load won’t be run again.

This breaks my color mode picker, because the loadFromLocalStorage function above doesn’t get re-run when navigating back from the settings page.

Fortunately, there’s a real easy fix!

I just needed to add an event listener for the window’s pageshow event, which gets fired when a page is restored for the bfcache, and call my function there:

window.addEventListener('pageshow', () => {
    loadFromLocalStorage();
});

There’s the possibility for a slight flicker as the page is restored from the bfcache in one color mode, and then quickly changed to the next, but I haven’t noticed it on any of my devices (if you notice it, please let me know!)

In Conclusion

This was a tricky one! The browser ecosystem is constantly changing, and you often don’t find out about new features like bfcache until you run into them in the wild.

I’m glad there was an easy fix for my situation – I’ve run into this bug on other people’s websites that use similar color mode changing, so hopefully it’ll work for them too.

I have mixed feelings about bfcache. Part of me is just yelling: “if we made websites better, we wouldn’t need this!”.

But sadly, we don’t make websites well. In our AI / JS slop world, people increasingly have to deal with slow-ass buggy websites that take up way too much time and bandwidth.

Given the state of things, bfcache is probably a big help.

But man, I wish the state of things were better!