Keeping the screen awake with the Screen Wake Lock API

A few weeks ago the Chromium based browsers added support for the Screen Wake Lock API. This API does exactly what the name says. It prevents the display from being switched off automatically. But as almost any other API it only shows its full potential when used in combination with other APIs. This post is shows how scroll events can be used together with the Screen Wake Lock API to achieve a more pleasant reading experience on blogs like this.

The Screen Wake Lock API (at least in its current shape) is fairly new. So far only the Chromium family is on the list of supported browsers. But that shouldn't stop us from using it to progressively enhance our sites. If our users use a browser which supports the API we can utilize it to give them a little more time to read what's currently on the screen without requiring them to tap on the display every so often just to keep it awake. This is especially interesting for websites with a lot of text like blogs. And that's why the technique described in this post is in use on this very page.

If you're browsing this page with any of the supported browsers you should at least have a minute to read what's on the screen before your display switches off. This should work even though you might have configured your device to automatically switch off the display after a shorter period of time.

Inspired by the monthly digests of my friend Stefan Judis which always come with a song that makes you stop coding I want to recommend a song which keeps you reading. "You're The Reason I'm Still Awake" by Photophob is a wonderful tune which also has a title that somehow matches this article. Feel free to press play before reading along.

Prior art

Preventing the screen from switching off is not a novel idea. As a user you can configure your system not to do that and there are command line tools which do that as well. But up until recently there wasn't a way to do the same thing from within a web page without any workarounds.

There is for example the nosleep package which keeps the screen awake by playing a hidden video. But this is of course not very energy efficient and it doesn't really work reliably. Having a proper web standard for this task is definitely better.

Despite of that the nosleep package is still a great choice when a workaround for unsupported browsers is strictly necessary. It even got an update lately which makes sure it uses the Screen Wake Lock API when available instead of playing the hidden video.

The Screen Wake Lock API

The Screen Wake Lock API is built around the WakeLock interface. An instance of which is available as wakeLock property of the navigator object. It has a method to request a wake lock. That method expects to be called with a type. But so far the only value this parameter can have is 'screen'.

const wakeLockSentinel = await navigator
    .wakeLock
    .request('screen');

If the request to get such a wake lock was successful the returned promise will resolve with a WakeLockSentinel. That's an object that can be used to release the wake lock again.

wakeLockSentinel.release();

That's already all we need to know about the API for the rest of this article. However the full API surface is a little bigger. Please check the article on web.dev by Pete LePage and Thomas Steiner if you're interested in more details.

Acquiring a wake lock with subscribable-things

Instead of using the native API directly we are going to use a library called subscribable-things. This is a library which provides wrappers for various browser APIs to make them usable in a reactive way. It aims to support those use cases which go beyond adding a single event listener and require a fair amount of code when implemented. And since version 1.9 it also comes with a wrapper for the Screen Wake Lock API.

That wrapper is called wakeLock and similar to its native counterpart it expects to be called with a type.

import { wakeLock } from 'subscribable-things';

const subscribe = wakeLock('screen');

As the name implies the thing returned by that function is subscribable. That means it's a function that expects to be called with a callback which will be invoked anytime the internal value is changing.

const unsubscribe = subscribe((isLocked) => {
    if (isLocked) {
        console.log('The screen is locked.');
    } else {
        console.log('The screen is not locked.');
    }
});

The callback will be called with true as soon as a wake lock could be aquired and it will be called again with false when the browser releases that lock. But it will automatically begin to aquire a new lock whenever that happens. This is necessary since the browser does for example release the lock if the user switches to another tab or enters fullscreen mode.

Once you're not interested in keeping the display awake anymore it's enough to call the function returned by the call to subscribe to release the internal wake lock again.

unsubscribe();

The nice thing about subscribable-things is that it works on its own as shown above but can also be used with many popular libraries for reactive programming like Callbags, XStream, Bacon.js, Kefir.js and RxJS. The latter is the one we will use now to listen to scroll events. However the concept should be adaptable for the other libraries in the list and it can be used with any of the modern frontend frameworks.

Listening to scroll events with RxJS

Just keeping the screen awake indefinitely is of course not very useful. It will drain the battery of our users and it will disrespect their device settings. It's better to establish a mechanism which signalizes us that the user is still interested in our content to make sure we only keep the screen awake while it's actually necessary. In case of a blog like this we can use scroll events as a measure for activity. As long as the user scrolls down to reveal more content it is safe to assume they are still reading.

As stated above subscribable-things only deals with fairly complicated APIs which involve some custom code when used reactively. It doesn't deal with events as this is already handled by many other libraries. RxJS for example comes with a dedicated helper function to turn events into an observable. The following snippet uses that function to capture scroll events emitted on the window object.

import { fromEvent } from 'rxjs';

const scrollEvent$ = fromEvent(
    window,
    'scroll',
    { passive: true }
);

Keeping the screen awake after each scroll event

We can now use the scrollEvent$ observable to obtain a wake lock whenever a scroll event is emitted. But scroll events are triggered very rapidly and we don't need to acquire a wake lock on each of those events as long as we still have one.

To achieve that we use the exhaustMap operator wich will ignore emitted scroll events unless the inner observable completes. In this particular case that's the observable which wraps the Screen Wake Lock API.

import { exhaustMap, from } from 'rxjs';
import { wakeLock } from 'subscribable-things';

const scrollSubscription = scrollEvent$.pipe(
    exhaustMap(() => from(wakeLock('screen')))
)
    .subscribe();

This does already work but it will keep the screen awake indefinitely after the first scroll event. We are still missing a mechanism to release the wake lock after a while. We need a way to tell the browser to release it again after a certain amount of time with no further scroll events.

In order to achieve that we build ourselves an observable which emits 60 seconds after the last scroll event by using the debounceTime operator. This operator is exactly built for that use case. It will buffer incoming events and will only emit the last of those events if there hasn't been any new event for the specified amount of time. The source of that observable will be the scrollEvent$ observable that we already have.

import { debounceTime } from 'rxjs';

const debouncedScrollEvent$ = scrollEvent$.pipe(
    debounceTime(60_000)
);

We can then use the debouncedScrollEvent$ observable to pass it to the takeUntil operator wich will terminate the wake lock subscription once the debouncedScrollEvent$ observable emits a value.

import { exhaustMap, from, takeUntil } from 'rxjs';
import { wakeLock } from 'subscribable-things';

const scrollSubscription = scrollEvent$.pipe(
    exhaustMap(() => from(wakeLock('screen')).pipe(
        takeUntil(debouncedScrollEvent$)
    ))
)
    .subscribe();

To make this really work one more tiny tweak is necessary. We need to make sure that both sequences of scroll events emitted by the scrollEvent$ observable are equal. So far they're not. Since observables are cold by default the second subscription to scrollEvent$ (caused by using debouncedScrollEvent$) will only be made after the very first scroll event already happened. Therefore it will not contain that event.

The publishReplay operator can be applied to change that. It will turn our observable into a multicast observable which always replays the last event upon subscription if there was already another active subscription.

The last piece of the puzzle is the refCount operator which will keep track of concurrent subscriptions and will complete the scrollEvent$ observable once all subscriptions are gone.

import {
   fromEvent,
   publishReplay,
   refCount
} from 'rxjs';

const scrollEvent$ = fromEvent(
    window,
    'scroll',
    { passive: true }
).pipe(
    publishReplay(1),
    refCount()
);

Catching errors

And as always we need to take into account that something can will go wrong. This will for sure be the case in unsupported browsers but can also happen if the browser decides to not accept the request for a wake lock. This might for example happen if the battery is almost empty.

We can use the catchError operator to catch any error by putting it at the end of our chain of operators. If an error occurs we replace our obsevable with an empty obsevable by using the EMPTY constant. That will effectively do nothing which is exactly what we want to happen in unsupported browsers.

import {
    EMPTY,
    catchError,
    exhaustMap,
    from,
    takeUntil
} from 'rxjs';
import { wakeLock } from 'subscribable-things';

const scrollSubscription = scrollEvent$.pipe(
    exhaustMap(() => from(wakeLock('screen')).pipe(
        takeUntil(debouncedScrollEvent$)
    )),
    catchError(() => EMPTY)
)
    .subscribe();

In any case don't forget to unsubscribe when you don't need that functionality anymore. This is especially important when your page is a single page app since it will otherwise cause a memory leak.

scrollSubscription.unsubscribe();

Conclusion

With all that in place we built a nice little progressive enhancement for any site that contains a lot of text. And if your display never switched off while you read this article it means it actually works (or you're a very fast reader).

Many thanks to Thomas Steiner and Jan-Niklas Wortmann for proof-reading this article and for providing valuable feedback.