loading...
Cover image for Instant Webpages and Terabytes of Data Savings Through the Magic of Service Workers ✨
The DEV Team

Instant Webpages and Terabytes of Data Savings Through the Magic of Service Workers ✨

ben profile image Ben Halpern ・5 min read

I am so excited to tell you all about the code that inspired this tweet...

I'm mostly excited because this affects pretty much all the users of our community in a positive way and unlocks a lot of possibilities for future development approaches and saves incredible amounts of data that would otherwise be shipped across the wire.

Demo time

To best demonstrate this feature, reload this page.

Unless the demo gods are frowning upon us, you should experience a shockingly fast response.

To further demonstrate this feature, head into the network tab in your browser's dev tools and throttle down your performance, perhaps to "slow 3G".

You should experience a page which immediately loads your top navigation and displays some loading text.

What's happening in either case is that the first part of the web request is being stored locally via Service Workers.

This demo may break if you're accessing this site via Twitter's in-app iOS browser or other edge cases I'm not aware of yet. Hence the above tweet.

The magic of Service Workers

The concept here is that Service Workers can act as a reverse proxy and execute code on behalf of a website before sending a page request. We've now leveraged this to store the "top" part of DEV, which was already established as the same for every page across the site.

Our approach is akin to the "App Shell Model" wherein a basic page exoskeleton is shipped to the browser and then the rest of the page is sent over via JSON in order to be filled in with frontend code. This approach dramatically adds to the efficiency of each request. However, given that our site is driven by cacheable documents meant for reading, and the fact that our team and tech stack leans more towards traditional backend templating through Ruby on Rails, I wanted to go in a different direction.

In experimenting with app-shell ideas it became clear that in most cases it actually takes longer to render useful content via the app shell model because there is more waiting around for code to execute at different stages, and there is no ability to leverage "streaming". It would also have forced us to re-architect a lot of what we do, and I mostly wanted to make this change invisible to our developers as long as they understand the basic constraints and possible gotchas in place.

Streams are a technology as old as time as far as the web is concerned. It's what enables the browser to progressively render a web page as the bits and bytes make their way across the universe and into your living room.

We use the ReadableStream class in order to piece together a page as its parts become available. The first "part" in our case is the top.

Our top is captured upon installation of the Service Workers in your browser, alongside the rest of the cacheable assets.

From our serviceworker.js file...

  self.addEventListener('install', event => {
    self.skipWaiting();

    // Populate initial serviceworker cache.
    event.waitUntil(
      caches.open(staticCacheName)
        .then(cache => cache.addAll([
          "/shell_top", // head, top bar, inline styles
          "/shell_bottom", // footer
          "/async_info/shell_version", // For comparing changes in the shell. Should be incremented with style changes.
          "/404.html", // Not found page
          "/500.html", // Error page
          "/offline.html" //Offline page
        ]))
    );
  });
Enter fullscreen mode Exit fullscreen mode

Even though we're not using the App Shell Model proper, shell still seemed like a good term for what's going on.

The top and bottoms are basically partials of the full page delivered as standalone HTML snippets with an endpoint. They are cached static via our CDN so this request doesn't hit our servers or waste a lot of download time. In the shell top we basically load everything in for styling and rendering that first part of the site. The shell bottom is our footer and any code that needs to execute there.

/async_info/shell_version is an endpoint designed to ensure the shell is kept in sync and updated when we make changes.

This is the meat of what's going on...

  function createPageStream(request) {
    const stream = new ReadableStream({
      start(controller) {
        if (!caches.match('/shell_top') || !caches.match('/shell_bottom')) { //return if shell isn't cached.
          return
        }

        // the body url is the request url plus 'include'
        const url = new URL(request.url);
        url.searchParams.set('i', 'i'); // Adds ?i=i or &i=i, which is our indicator for "internal" partial page
        const startFetch = caches.match('/shell_top');
        const endFetch = caches.match('/shell_bottom');
        const middleFetch = fetch(url).then(response => {
          if (!response.ok && response.status === 404) {
            return caches.match('/404.html');
          }
          if (!response.ok && response.status != 404) {
            return caches.match('/500.html');
          }
          return response;
        }).catch(err => caches.match('/offline.html'));

        function pushStream(stream) {
          const reader = stream.getReader();
          return reader.read().then(function process(result) {
            if (result.done) return;
            controller.enqueue(result.value);
            return reader.read().then(process);
          });
        }
        startFetch
          .then(response => pushStream(response.body))
          .then(() => middleFetch)
          .then(response => pushStream(response.body))
          .then(() => endFetch)
          .then(response => pushStream(response.body))
          .then(() => controller.close());
      }
    });

    return new Response(stream, {
      headers: {'Content-Type': 'text/html; charset=utf-8'}
    });
  }
Enter fullscreen mode Exit fullscreen mode

?i=i is how we indicate that a page is part of "internal" navigation, a concept that already existed within our app which set us up to implement this change without much business logic on the backend. Basically this is how someone requests a page on this site that does not include the top or bottom parts.

The crux of what’s going on here is that we take the top and bottom from a cache store and get to work rendering the page. First comes the already available top, as we get to work streaming in the rest of the page, and then finishing off with the bottom part.

This approach lets us generally ship many fewer bytes while also controlling the user experience with more precision. I would like to add more stored snippets for use in areas of the site that can most make use of them. I especially want to do so on the home page. I think we can store more of the home page this way and ultimately render a better experience more quickly in a way that feels native in the browser.

We have configurations such as custom fonts in user settings and I think this can be incorporated smartly into Service Workers for the best overall experience.

There was a period of edge case discovery and bugs that needed to be ironed out once this was deployed. It was hard to catch everything upfront, especially the parts which are inherently inconsistent between environments. Conceptually, things are about the same as they were before for our developers, but there were a few pages here and there which didn't work as intended, and we had some cached content which didn't immediately play well. But things have been mostly ironed out.

Early returns indicate perhaps tens of milliseconds are being saved on requests to our core server which would have otherwise had to whip up our header and footer and send it all across the wire.

There is still a bug that makes this not quite work properly in the Twitter in-app browser for iOS. This is the biggest head scratcher for me, if anybody can track this down, that would be helpful. iOS, in general, is the platform that is least friendly to Service Workers, but the basic Safari browser seems to work fine.

Of course, all the work that went into this is open source...

GitHub logo forem / forem

For empowering community 🌱


Forem 🌱

For Empowering Community

ruby version rails version Travis Status for thepracticaldev/dev.to Code Climate maintainability Code Climate test coverage Code Climate technical debt CodeTriage badge Dependabot Badge GitPod badge Netlify badge GitHub code size in bytes GitHub commit activity GitHub issues ready for dev Honeybadger badge Knapsack Pro Parallel CI builds for dev.to

Welcome to the Forem codebase, the platform that powers dev.to. We are so excited to have you. With your help, we can build out Forem’s usability, scalability, and stability to better serve our communities.

What is Forem?

Forem is open source software for building communities. Communities for your peers, customers, fanbases, families, friends, and any other time and space where people need to come together to be part of a collective See our announcement post for a higher level overview of what Forem is.

dev.to (or just DEV) is hosted by Forem. It is a community of software developers who write articles, take part in discussions, and build their professional profiles. We value supportive and constructive dialogue in the pursuit of great code and career growth for all members. The ecosystem spans from beginner to advanced developers, and all are welcome to find their…

Further Reading

Stream Your Way to Immediate Responses
2016 - the year of web streams

Happy coding ❤️

Discussion

pic
Editor guide
Collapse
ben profile image
Ben Halpern Author

While on the topic of new and interesting improvements to the DEV product, I can't help but give a shout out to the foundational work we've been doing to have a more design-driven 2020.

We recognize that we have a lot of de-cluttering and visual hierarchy improvements to be made and I really can't wait until we get to start shipping the fruits of that labor.

This is essentially wholly off-topic, but I think things are truly coming together incredibly nicely as far as product and engineering go, and as we enable the launching of more communities hosted on our open source software, all this work gets replicated for everybody to use.

Collapse
pavelloz profile image
Paweł Kowalski

Next step would be to split actual post from comments. I dont know if thats not overkill, but if you are experimenting and trying to squeeze last bit of performance, then lazyloading/deferring comments comes to mind as a good idea from top-to-bottom flow of things.

Collapse
ben profile image
Collapse
pavelloz profile image
Paweł Kowalski

Im glad im not the only one spending absurd amounts of time to shave those last couple % from RUMs :-)

BTW. Rails had (and still do, but not a lot of people use it) a pretty good plug and play solution like ~8 years ago called Turbolinks. Im still amazed how much it can achieve with how little effort. Good old times. :)

Collapse
somedood profile image
Basti Ortiz (Some Dood)

We've got a winner over here.

Collapse
dansilcox profile image
Dan Silcox

Nice! Typo / copy paste fail in the second code snippet I think -

if (!caches.match('/shell_top') || !caches.match('/shell_top')) { //return if shell isn't cached.
    return
}

Second one should be shell_bottom right?

Collapse
ben profile image
Ben Halpern Author

Ha! Nice catch. Luckily for us it's highly unlikely shell_bottom is likely not missing unless shell_top is also missing. But we should definitely fix.

I'm going to start by fixing it in the demo above before patching it in the real code.

Feel free to do the honors if you'd like to submit the fix in the app itself...

github.com/thepracticaldev/dev.to/...

Collapse
dansilcox profile image
Dan Silcox

github.com/thepracticaldev/dev.to/... - hope I did it right, not done a PR on open source before!

Collapse
dansilcox profile image
Dan Silcox

And by the way definitely got the near instant refresh effect you mentioned!

Collapse
ziizium profile image
Habdul Hazeez

Same here. Even when i throttled the connection to Regular 2G.

Collapse
yaser profile image
Yaser Al-Najjar

Day after another, you're becoming a web-perf wizard, Ben 😁

Collapse
ben profile image
Collapse
reddyaravind178 profile image
aravind_reddy

hey ben, while i was going through this article.. i opened the link https://dev.to/security nothing is being rendered except the header and footer when i observed the network tab i can see the content is being fetched from service worker.. the same url i tried in incognito, it worked fine for the first time then from second time the same thing is happening there as well... so if somehow the page doesn't load properly and it is being cached.. from next time on wards it just shows the cached page which is not proper??

update: when i unregistered the service worker from chrome-dev-tools... the page loaded correctly..

Collapse
abdellani profile image
Mohamed ABDELLANI

Now, I understand why when I access https://127.0.0.1:3000 I get the offline page when the backend is off. Sometimes, when I run another project on the same port, I see the DevCommunity's header and footer loaded in that project.

Thanks for sharing.

Collapse
cadonau profile image
Markus Cadonau

There is still a bug that makes this not quite work properly in the Twitter in-app browser for iOS.

Aha! I was wondering why the in-app browser in Tweetbot would lately—once again, after it being solved for a while—not always load the pages. Good luck in figuring it out!

Collapse
nikitavoloboev profile image
Nikita Voloboev

Great news. One thing that I personally wish dev.to had is ability for users to bring edits to the posts.

That's my main issue from moving my blog from being hosted on GitHub and rendered with some Gatsby/Next/Hugo/..

The ability for anyone to view the source and make edits and send PRs is super valuable. Curious if this issue will be addressed or if it is even possible to address.

Collapse
aslasn profile image
Ande

Ben, I have a question. Why there's no css/js and other static files cached through service workers? Is there a specific reason or is it just because normal browser default cache works good enough(maybe even better :/)?

I didn't know this thread existed. It was hard to examine through dev.to source with the devtools when i didn't even know a lot of things used there. The github would be even harder for me XD

Collapse
timjkstrickland profile image
Tim JK Strickland

It's....it's so beautiful

Collapse
mkimont profile image
Matt Kimek

I prefer using cache on proxy site. Less logic. If you need specific jsons, html, css, resources you can point them to CDN. /500.html, /css.css, /comment-123.json -> will be pointing to CDN on production server.
You can add fallback to use your production server if they doesn't exist on CDN

Collapse
tylerlwsmith profile image
Tyler Smith

This is super neat! I want to find a project to try this approach on.

I just migrated a site from WordPress to Next.js, and while the rebuilt Next.js site is lightening-fast on a good connection, WordPress is actually much faster on slow 3G because of streaming. Your approach feels like it would be a great middle ground.

Have you thought about noindex-ing /shell_top and /shell_bottom using robots.txt or X-Robots-Tag? I've had site partials end up indexed by Google before so I always worry 😅

Collapse
yashints profile image
Yaser Adel Mehraban

Nice, LYW team 💖

Collapse
ben profile image
Collapse
donnietd profile image
DonnieTD

Did Kyle Simpson not predict that the possibilities of service workers will be the future of growth in JavaScript ?

Beautiful article !

The demo gods were good today!