Menu

#2382 Runtime caching strategy needed for media?

closed
nobody
2020-03-23
2020-02-27
Anonymous
No

Originally created by: daffinm
Originally owned by: jeffposnick

Library Affected:
"workbox-sw"

Browser & Platform:
"all browsers".*

Issue or Feature Request Description:

As @jeffposnick says here:

If you want to serve the media from the cache, you should explicitly add it to the cache ahead of time. This could happen either via precaching, or via calling cache.add() directly. Using a runtime caching strategy to add the media file to the cache implicitly is not likely to work, since at runtime, only partial content is fetched from the network via a Range request.

The good news is that workbox will cache and serve media perfectly well (once you have ironed out the usual assortment of wrinkles).

Consequences?

The bad news is that no media at all will serve until the service worker activates, and this only happens once all media files in the app have been cached. You cannot even get metadata for a media file until the service worker goes into the activated state...

Not very user-friendly?

Picture an app that makes extensive use of media. These are not uncommon. If you click a link to the site you will have to wait for every cached media file to download before being able to play just one of them. But what if you just wanted to "try before you buy"? And what if you don't like my PWA? Tough. You just had to download a ton of stuff you are not interested in.

In order to release my app I have had to create two service workers for it. The first is the default one that does not cache any media. The second one precaches all media in the app and I switch over to this once the user has chosen to install the app (add to home screen). This seems fair. But the user still has to wait until every media file has downloaded before being able to play one of them after they install.

On-demand caching needed for media?

Ideally, media files would start playing immediately and get cached in the background so that the user would not have to wait for them to finish downloading before playing.

Alternatively, first request for a media file would result in the file being downloaded and fully cached before playing. Subsequently, the file would play from the cache. This seems technically simper.

And if the remote file gets updated you would want to update the cache.

Anyone caching media in a PWA is going to run across this issue.

Thoughts?

Discussion

  • Anonymous

    Anonymous - 2020-02-27
     
  • Anonymous

    Anonymous - 2020-02-27

    Originally posted by: jeffposnick

    Hello!

    Could you provide some more information so that I could better understand what your service worker is doing?

    The bad news is that no media at all will serve until the service worker activates, and this only happens once all media files in the app have been cached. You cannot even get metadata for a media file until the service worker goes into the activated state...

    This is the part I don't understand—why won't any media serve until the service worker activates? Is it because the precaching that's happening in parallel during the install event causes enough bandwidth contention that the client page can't stream bytes quickly enough for smooth playback?

    What I would assume is that before the service worker activates and takes control of any client pages, all existing media files would be just as playable—from a technical, if not bandwidth point of view—as they would be if there were no service worker involvement at all.

    Ideally, media files would start playing immediately and get cached in the background so that the user would not have to wait for them to finish downloading before playing.

    The issue here is that each browser handles streams of media files differently, varying on the media format, and some of the ways that streaming takes place does not lend itself to runtime caching in a service worker. I can't think of any viable approach that could, for instance, account for the out-of-order Range segments that might be streamed to the browser when a user scrubs through a media's timeline, and pieces them all together (hopefully not missing any bytes!) into a cacheable, complete Response object.

    If you're playing back media in a controlled environment—perhaps you disable scrubbing and other playback controls, and you know that the media formats you're using will always trigger a single HTTP request that doesn't use Range headers in all browsers—then you could just use Workbox's runtime caching out of the box, without having to do anything special. It's just that I'm not aware of any playback scenarios where that's what happens.

     
  • Anonymous

    Anonymous - 2020-03-05

    Originally posted by: daffinm

    Hi @jeffposnick. Thanks for responding.

    I think that the problem I am seeing may be caused by the fact that my single page app has a lot of audio files in it. I am using lazy loading for the audio (pseudo) pages, so the audio elements are not all present in the DOM at first. They get added when the page is accessed and added for the first time. But some audio pages have over 10 audio elements. Further testing reveals that the audio is playable before the service worker activates, so the delay I am seeing is probably to do with how the browser (in this case Chrome) is handling multiple audio elements. It looks more like a bandwidth issue.

    As for the second part of my question - and the title - could one intercept a request for an audio (or other media) file in the service worker, ignore the range request headers, cache the entire file and then respond to the original range request with a response from the cache (which would use a range requests plugin)? Then you would have cache on demand (runtime caching) for media files without the issues you point out.

     
  • Anonymous

    Anonymous - 2020-03-06

    Originally posted by: jeffposnick

    As for the second part of my question - and the title - could one intercept a request for an audio (or other media) file in the service worker, ignore the range request headers, cache the entire file and then respond to the original range request with a response from the cache (which would use a range requests plugin)? Then you would have cache on demand (runtime caching) for media files without the issues you point out.

    What you describe is possible, using the building blocks that are already available in Workbox, but I'm not sure that it would lead to a great experience for your users. The reason that many browsers will use Range: request headers to stream media files is that waiting for 1% of a media file (or some other small percentage) to finish transferring before starting playback leads can often lead to a better experience than waiting for the entirety of a media file to finish transferring.

    If you wanted to try it for yourself, you could play around with something like (untested):

    import {registerRoute} from 'workbox-routing';
    import {CacheOnly} from 'workbox-strategies';
    import {RangeRequestsPlugin} from 'workbox-range-requests';
    
    const cacheName = 'audio-cache';
    const cacheOnlyAudioStrategy = new CacheOnly({
      cacheName,
      plugins: [new RangeRequestsPlugin()],
    });
    
    registerRoute(
      // Match any subresource request that will be used for audio playback.
      ({request}) => request.destination === 'audio',
    
      async ({event, request}) => {
        event.waitUntil((() => {
          const cache = await caches.open(cacheName);
          // If there's no match, cache the full response.
          if (!(await cache.match(request))) {
            await cache.add(request)
          }
    
          // At this point, the cache is populated,
          // so return the partial response.
          return await cacheOnlyAudioStrategy.handle({request}),
        })());
      }
    );
    
     
  • Anonymous

    Anonymous - 2020-03-06

    Ticket changed by: jeffposnick

    • status: open --> closed
     
  • Anonymous

    Anonymous - 2020-03-09

    Originally posted by: daffinm

    Thanks @jeffposnick. But I think the real issue here is bandwidth contention as per [#570]. On a slow mobile data connection Workbox precaching hogs the bandwidth. When precaching completes, and the service worker activates, the connection is free again. This makes it look like media cannot be served until the service worker activates.

    I am solving this now with a combination of preload=none for the audio elements so that adding them to the page does not create a tussle for bandwidth, and delaying service worker registration until I am pretty sure that the app is entirely functional. But this is kind of sub-optimal..

     

    Related

    Tickets: #570

  • Anonymous

    Anonymous - 2020-03-09

    Originally posted by: jeffposnick

    It doesn't sound like precaching is going to end up being the best solution for you, unless you do something like show a "Downloading assets..." loading screen when the SW is first installed, preventing users from doing anything else while that's going on, like some native apps might do.

    Runtime caching, possibly triggered when you detect that a user is idle, sounds like the most reasonable balance.

     
  • Anonymous

    Anonymous - 2020-03-21

    Originally posted by: daffinm

    Just in case anyone else needs this, I now have a nice solution to runtime audio (or media) caching with Workbox, based on @jeffposnick 's code above.

    In your service worker:

    const AUDIO_CACHE_NAME = `runtime-audio`;
    
    // TODO consider adding header check to see if external file is newer and update cache if so.
    async function addToAudioCache(url) {
        assert.isTrue(url.endsWith('.mp3'), 'URL is not for audio/mp3', url);
        const cache = await caches.open(AUDIO_CACHE_NAME);
        if (!(await cache.match(url))) {
            await cache.add(url);
        }
    }
    
    const audioRouteMatcher = ({url, event}) => {
        let matches = event.request.url.match(/.*\.mp3$/);
        return matches;
    };
    const audioRouteHandlerCacheOnly = new workbox.strategies.CacheOnly({
        cacheName: AUDIO_CACHE_NAME,
        plugins: [
            new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [200]}),
            new workbox.rangeRequests.RangeRequestsPlugin(),
        ],
        matchOptions: {
            // This is needed since precached resources may have a ?_WB_REVISION=... URL param.
            ignoreSearch: true,
            // Firebase vary header caused cache match to fail for mp3 until added this.
            ignoreVary: true,
        }
    });
    // Register the audio router.
    workbox.routing.registerRoute(
        audioRouteMatcher,
        ({event, request}) => {
            event.respondWith((async () => {
                await addToAudioCache(request.url);
                return audioRouteHandlerCacheOnly.handle({request});
            })());
        }
    );
    

    The first time a user clicks on an audio player (preload=none) a full response is cached first and then the request is served from the cache. If they are on a fast network this is instantaneous. If they are on a slow network the audio player spins until the media is cached and then it plays. This is just what I have been looking for.

    Advanced javascript sytax still baffles me so I had to break it down into bits, and put them together until they worked. (No doubt some brighter person can do it all in one line :-)

     
  • Anonymous

    Anonymous - 2020-03-21

    Originally posted by: jeffposnick

    Thanks for sharing that code!

     
  • Anonymous

    Anonymous - 2020-03-22

    Originally posted by: daffinm

    No problem.

    And here is a version of the audio cache function that updates the cache if the external resource has been updated.

    async function addToAudioCacheV2(url) {
        assert.isTrue(url.endsWith('.mp3'), 'URL is not for audio/mp3', url);
        const KEY_CONTENT_LENGTH = 'Content-Length';
        const cache = await caches.open(AUDIO_CACHE_NAME);
        let matchOptions = {ignoreSearch: true, ignoreVary: true};
        const cachedResponse = await cache.match(url, matchOptions);
        if (cachedResponse) {
            let cachedContentLength = cachedResponse.headers.get(KEY_CONTENT_LENGTH);
            if (cachedContentLength) {
                try {
                    // For speed just fetch the HEAD for the meta info.
                    const externalResponse = await fetch(url, {method: 'HEAD'});
                    let externalContentLength = externalResponse.headers.get(KEY_CONTENT_LENGTH);
                    if (externalContentLength && (cachedContentLength !== externalContentLength)) {
                        await cache.add(url);
                    }
                }
                catch (error) {
                    // Normally if offline suddenly.
                    debug.warn(`Error performing refresh check for ${AUDIO_CACHE_NAME}:\n - URL: ${url}\n - Error: ${error.stack || error.message}`);
                }
            }
        }
        else {
            await cache.add(url);
        }
    }
    

    Only problem I can see with this is that the cache can update when you are playing a track since we are dealing with partial/range requests. Not such a big deal in my case.

     
  • Anonymous

    Anonymous - 2020-03-23

    Originally posted by: daffinm

    Final comment here. I have finished my solution for media caching using Workbox and documented it in the following, working project. https://github.com/daffinm/audio-cache-test

    Thanks everyone. And stay well.

     

Log in to post a comment.