The three Rs of web performance

Matthew Somerville

BrumJS

17th April 2018

Why

2006

“Four seconds is the maximum length of time an average online shopper will wait for a Web page to load before potentially abandoning a retail site.”

— Akamai

2009

“40% will abandon a web page if it takes more than three seconds to load … 52% of online shoppers claim that quick page loads are important for their loyalty to a site.”

— Akamai

2010

“Two seconds is the threshold for ecommerce website acceptability. At
Google, we aim for under a half second.”

— Google

2012

“67% of consumers cite slow websites as the main cause of basket abandonment”

— Brand Perfect

2015

“46% of consumers say that waiting for pages to load is what they dislike the most when browsing the mobile web.”

— Google

2016

“The average load time for mobile sites is 19 seconds over 3G connections 53% of mobile site visits are abandoned if pages take longer than three seconds to load.”

— DoubleClick

2017

“The average time it takes to fully load a mobile landing page is 22 seconds, according to a new analysis  That’s a big problem.”

— Google

2018

“The bad news is that it still takes about 15 seconds 79% of pages were over 1MB, 53% over 2MB and 23% over 4MB”

— Google

2018

“At the BBC we’ve noticed that, for every additional second a page takes to load, 10 per cent of users leave. This is why, if the BBC site is slowing down due to load, certain features will automatically switch off to bring the speed up again.”

— BBC

Three Rs

Reduce

Redirects

Response time

(ideally 0.2s)

Response content

  • Images
  • Minification
  • Compression
  • Less of it!

jQuery

  • jQuery on traintimes.org.uk: 19.2KB
    (a really old version; jQuery 2 is 29KB)

  • Do we need it at all?

  • Without: 2KB

  • References:

    • MDN

    • “You might not need jQuery”

Dropping jQuery

var $ = {};

$.id = function(id) { return document.getElementById(id); };

$.get = function(url, fn) {
    var request = new XMLHttpRequest();
    request.open('GET', url, true);
    request.onload = function() {
        if (request.status < 200 || request.status >= 400) {
            return;
        }
        fn(request.responseText);
    };
    request.send();
};

$.getJSON = function(url, fn) {
    $.get(url, function(text) {
        data = JSON.parse(text);
        fn(data);
    });
};

Dropping jQuery

Element.prototype.load = function(url, data, fn) {
    var self = this;
    $.get(url, function(text) {
        self.innerHTML = text;
        fn(text);
    });
};

if (!Element.prototype.closest) {
    Element.prototype.closest = function(s) {
        var matches = (this.document || this.ownerDocument).querySelectorAll(s),
            i,
            el = this;
        do {
            i = matches.length;
            while (--i >= 0 && matches.item(i) !== el) {};
        } while ((i < 0) && (el = el.parentElement)); 
        return el;
    };
}

Dropping jQuery

NodeList.prototype.addEventListener = HTMLCollection.prototype.addEventListener
  = function(name, fn) {
    [].forEach.call(this, function(elem) {
        elem.addEventListener(name, fn);
    });
};

NodeList.prototype.click = HTMLCollection.prototype.click = function(fn) {
    this.addEventListener('click', function(e) {
        e.preventDefault();
        fn.call(this);
    });
};

/* https://github.com/ded/domready | MIT */
$.ready = document.documentElement.className==='js'
    ? (function(){
    var e=[],t,n=document,r=n.documentElement.doScroll,i="DOMContentLoaded",
        s=(r?/^loaded|^c/:/^loaded|^i|^c/).test(n.readyState);
    return s||n.addEventListener(i,t=function(){n.removeEventListener(i,t),s=1;
    while(t=e.shift())t()}), function(t){s?setTimeout(t,0):e.push(t)}
    })() : function(t){};

Dropping jQuery

$.height = function(el) {
    return Array.prototype.reduce.call(el.childNodes, function(p, c) {
        if (c.nodeType === 1) {
            var style = getComputedStyle(c);
            p += (c.offsetHeight || 0) + parseInt(style.marginTop) + parseInt(style.marginBottom);
        } else if (c.nodeType === 3) {
            var range = document.createRange();
            range.selectNodeContents(c);
            var rect = range.getBoundingClientRect();
            p += rect.height;
        }
        return p;
    }, 0);
};

Element.prototype.toggle = function(force_show, extra_div) {
    if (force_show === false) {
        this.style.maxHeight = '0px';
    } else if (force_show || this.style.maxHeight == '' || this.style.maxHeight == '0px') {
        this.style.display = 'block';
        this.style.maxHeight = ($.height(this) + (extra_div ? $.height(extra_div) : 0)) + 'px';
    } else {
        this.style.maxHeight = '0px';
    }
};

Dropping jQuery

  • FixMyStreet – dropped on the front page

  • Only the necessary JavaScript:

    • Translation, geolocation, lazy loading, a prefetch polyfill – and then prefetch the rest

    • For traintimes.org.uk – split the code into separate files for each part of the site

Lazy loading

if ('IntersectionObserver' in window) {
    document.documentElement.className += ' lazyload';
}
html.lazyload .js-lazyload {
  background-image: none;
}

.js-lazyload class on elements with CSS background images you wish to lazy load

11KB saved on FixMyStreet footer

Lazy loading

(function(){
    if (!('IntersectionObserver' in window)) {
        return;
    }

    const observer = new IntersectionObserver(onIntersection, {
        rootMargin: "50px 0px"
    });

    const images = document.querySelectorAll(".js-lazyload");
    images.forEach(image => {
        observer.observe(image);
    });

    function onIntersection(entries, observer) {
        entries.forEach(entry => {
            if (entry.intersectionRatio > 0) {
                observer.unobserve(entry.target);
                entry.target.classList.remove('js-lazyload');
            }
        });
    }
})();

Reuse

Caching

Service worker / AppCache

AppCache

Avoid if possible!

FixMyStreet Pro used by staff with Lumia phones

AppCache only possible option

Have it working, but intercepts all non-200 pages :(

Service workers

var OFFLINE_NEEDED = [
  '/offline.php?1494080451',
  '/railway2.min.css?1523191060',
  '/train3.gif?1253578204',
  '/js/core.min.js?1523191062',
  '/js/results.min.js?1523191066',
  '/js/live.min.js?1523191066',
];

var OFFLINE_NICE = [
  '/favicon.ico?1253578205',
  '/i/icon-192x192.png?1482499945',
  '/calendar.gif?1125263580',
  '/file_acrobat.gif?1207918892',
];

Service workers

self.addEventListener("install", event => {
  event.waitUntil(
    updateCache("static", OFFLINE_NEEDED, OFFLINE_NICE)
    .then(() => console.log("Installed"))
    .then(() => self.skipWaiting())
  );
});

self.addEventListener("activate", event => {
  event.waitUntil(
    clearCache("static", OFFLINE_NEEDED.concat(OFFLINE_NICE))
    .then(() => console.log("Activated"))
    .then(() => self.clients.claim())
  );
});

Service workers

var updateCache = (cache, mandatory, optional) =>
  caches.open(cache).then(cache =>
    (cache.addAll(optional), cache.addAll(mandatory)));

var clearCache = (cache, urls) =>
  caches.open(cache).then(cache =>
    cache.keys().then(keys =>
      Promise.all(
        keys.filter(req =>
          urls.map(u =>
            'https://traintimes.org.uk' + u).indexOf(req.url) === -1)
          .map(x => cache.delete(x))
      )));

var findInCache = request =>
  new Promise((resolve, reject) =>
    caches.match(request).then(response =>
      response ? resolve(response) : reject())
  );

Service workers

var addToCache = (cache, request, response) =>
  (resp = response.clone(),
   caches.open(cache)
    .then(cache => resp.ok ? cache.put(request, resp) : cache.delete(request))
    .catch(err => console.log(err)),
    response);

var cacheAndFetch = request =>
  caches.match(request)
    .then((cached, networked) =>
      (networked = fetch(request)
        .then(response => addToCache("pages", request, response))
        .catch(err => findInCache('/offline.php?1494080451'))
        .catch(offlineResponse),
      cached || networked))

var offlineResponse = () =>
  new Response('Service Unavailable', { status: 503,
    statusText: 'Service Unavailable',
    headers: { 'Content-Type': 'text/html' }});

Service workers

self.addEventListener("fetch", event => {
  var request = event.request;
  var url = new URL(request.url);

  if (request.method !== "GET" || url.origin !== location.origin) { return; }

  if (request.method === "GET" && url.pathname == '/') {
    return event.respondWith(cacheAndFetch(request));
  }

  if (request.headers.get("Accept").indexOf("text/html") !== -1) {
    var req = new Request(request.url, {
      method: 'GET',
      headers: request.headers,
      mode: request.mode == 'navigate' ? 'cors' : request.mode,
      credentials: request.credentials,
      redirect: request.redirect
    });

    return event.respondWith(
      fetch(req)
      .then(response => addToCache("pages", request, response))
      .catch(err => (console.log('HTML fetch failed', err), findInCache(request)))
      .catch(err => (console.log('HTML cache lookup failed', err), findInCache('/offline.php?1494080451')))
      .catch(offlineResponse)
    );
  }

  event.respondWith(
    findInCache(request)
    .catch(err => fetch(request))
    .catch(offlineResponse)
  );
});

Service workers

self.addEventListener("fetch", event => {
  [...]
  if (request.headers.get("Accept").indexOf("text/html") !== -1) {
    [...]
    return event.respondWith(
      fetch(req)
      .then(response => addToCache("pages", request, response))
      .catch(err => findInCache(request))
      .catch(err => findInCache('/offline.php?1494080451'))
      .catch(offlineResponse)
    );
  }

  event.respondWith(
    findInCache(request)
    .catch(err => fetch(request))
    .catch(offlineResponse)
  );
});

Service workers

var trimCache = (cacheName, maxItems) =>
  caches.open(cacheName)
    .then(cache =>
      cache.keys().then(keys => {
        if (keys.length > maxItems) {
          cache.delete(keys[0])
            .then(trimCache(cacheName, maxItems));
        }
      })
    );

self.addEventListener('message', function(event) {
  if (event.data.command == 'trimCaches') {
    trimCache("pages", 50);
  }
});

Restructure

Progressive enhancement

Prioitize visible content

FixMyStreet logo

#site-logo {
  display: block;                                 
  width: 175px; 
  height: 60px;
  background-position: 0 50%;                     
  background-repeat: no-repeat;                   
  background-size: 175px 35px;                    
  @include svg-background-image("/cobrands/fixmystreet/images/site-logo");
  text-indent: -999999px;
}
<a href="/" id="site-logo">[% site_name %]</a>

FixMyStreet logo

<a href="/" id="site-logo" aria-label="[% site_name %]">[% FILTER collapse %]
    [% IF c.req.uri.path == '/' %]
        [% INSERT site_logo_with_fallback.svg %]
    [% ELSE %]
        <span class="site-logo__fallback"></span>
    [% END %]
[% END %]</a>

FixMyStreet logo

<svg width="175" height="35" viewBox="0 0 175 35" xmlns="http://www.w3.org/2000/svg">
  <!--[if gt IE 8]><!-->
  <switch>
    <g class="site-logo__svg">
      [...copy of site-logo.svg...]
    </g>
    <foreignObject>
      <!--<![endif]-->
      <span class="site-logo__fallback"></span>
    </foreignObject>
  </switch>
</svg>

https://codepen.io/tigt/post/inline-svg-fallback-without-javascript-take-2

FixMyStreet logo

@namespace svg "http://www.w3.org/2000/svg";
    
#site-logo {
    text-indent: 0;
    background: none;
    svg { margin-top: ((60px - 35px) / 2); }
}

.site-logo__svg {
    display: none;
    visibility: hidden;
}

svg|g.site-logo__svg {
    display: inline;
    visibility: visible;
}

Eliminate render blocking

CSS blocking

FixMyStreet front page inlines critical CSS always

traintimes.org.uk inlines all its CSS first time

CSS blocking

<style>
[% critical %]
</style>

<noscript><link rel="stylesheet" href="[% base_css %]"></noscript>

<link id="preload_base_css" rel="preload" href="[% base_css %]" as="style">

<script nonce="[% csp_nonce %]">
document.getElementById('preload_base_css').onload = function(){this.rel='stylesheet'};
/*! loadCSS & rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */
</script>
<link rel="stylesheet" href="[% base_css %]" media="(min-width:48em)">
<link rel="stylesheet" href="[% layout_css %]" media="screen and (min-width:48em)">
<!--[if (lt IE 9) & (!IEMobile)]>
    <link rel="stylesheet" href="[% layout_css %]">
<![endif]-->

CSS blocking

  • https://github.com/filamentgroup/loadCSS
  • https://github.com/pocketjoso/penthouse

JS blocking

Inline JavaScript

var fixmystreet = fixmystreet || {};

(function(D){
  var E = D.documentElement;
  E.className = E.className.replace(/\bno-js\b/, 'js');
  var iel8 = E.className.indexOf('iel8') > -1;
  var type = Modernizr.mq('(min-width: 48em)') || iel8 ? 'desktop' : 'mobile';
  var meta = D.getElementById('js-meta-data');
  if ('IntersectionObserver' in window) {
    E.className += ' lazyload';
  }
  fixmystreet.page = meta.getAttribute('data-page');
  fixmystreet.cobrand = meta.getAttribute('data-cobrand');
  if (type == 'mobile') {
    E.className += ' mobile';
    if (fixmystreet.page == 'around') {
      E.className += ' map-fullscreen only-map map-reporting';
    }
  }
})(document);

Geolocation

if ('geolocation' in navigator) {
  // add a "Locate me" <a> to page
}

JavaScript in <head>, blocked before display

Move JavaScript to end of <body>, no blocking,
but now a reflow of the page as it gets added

.no-js #geolocate_link {
    display: none !important
}
<a id="geolocate_link" class="btn--geolocate">
  or locate me automatically
</a>

Average page weight

Desktop: 1713KB

Mobile: 1546KB

— httparchive.org

median figures

traintimes.org.uk

results page

HTML

CSS

JavaScript

Images

Manifest

2K

1.9K

2.3K

1.8K

0.4K

fixmystreet.com

front page, mobile

HTML

CSS
JavaScript

Images

10K

15K

5K

8K

desktop has 19K CSS, 66K images, 77K webfont

Thanks for listening!