Matthew Somerville
BrumJS
17th April 2018
“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
“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
“Two seconds is the threshold for ecommerce website acceptability. At
Google, we aim for under a half second.”
“67% of consumers cite slow websites as the main cause of basket abandonment”
— Brand Perfect
“46% of consumers say that waiting for pages to load is what they dislike the most when browsing the mobile web.”
“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
“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.”
“The bad news is that it still takes about 15 seconds … 79% of pages were over 1MB, 53% over 2MB and 23% over 4MB”
“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
(ideally 0.2s)
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”
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);
});
};
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;
};
}
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){};
$.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';
}
};
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
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
(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');
}
});
}
})();
FixMyStreet Pro used by staff with Lumia phones
AppCache only possible option
Have it working, but intercepts all non-200 pages :(
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',
];
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())
);
});
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())
);
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' }});
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)
);
});
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)
);
});
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);
}
});
#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>
<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>
<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
@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;
}
FixMyStreet front page inlines critical CSS always
traintimes.org.uk inlines all its CSS first time
<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]-->
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);
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>
Desktop: 1713KB
Mobile: 1546KB
— httparchive.org
median figures
results page
HTML
CSS
JavaScript
Images
Manifest
2K
1.9K
2.3K
1.8K
0.4K
front page, mobile
HTML
CSS
JavaScript
Images
10K
15K
5K
8K
desktop has 19K CSS, 66K images, 77K webfont