With Edgio Applications, nearly every website can boost their front-end performance. Easiest way to ace Largest Contentful Paint (part of Core Web Vitals)? Combine your frontend optimisations with the powerful caching and predictive prefetching offered by Edgio.
Introduction
A good user experience starts with delivering content as fast as possible. With the backbone of Edgio Applications CDN, Edgio Applications promises fast content delivery, and with their predictive prefetching, a super fast navigation. In this study, I set out to improve nike.com! by caching their home page, product listing pages & product display pages. I aimed for at least 50% boost in the performance, but voila! seems like I was able to pull off even more than that over a cup of tea (only code haha).
Disclaimer
I’m a Technical Customer Success Manager at Edgio Applications, but this is purely my ideation & work.
Leveraging Edgio Applications
The starting point to ship speed with Edgio Applications is at their WebApp CDN guide. Overall, Edgio Applications will become the main CDN for the site/origin server (here, www.nike.com). Once a response is received from the origin, it gets cached over Edgio Applications's globally distributed edge network. In browser, then those cached pages, assets, api’s, etc. can be prefetched.
Show me the code!
I started with installing the Edgio CLI, by the following command:
# Using NPM
npm i -g @edgio/cli
# Using Yarn
yarn global add @edgio/cli
Creating a new project with Edgio Applications, is then just a matter of command:
edgio init
Irrespective of the initial configuration by the CLI, I alter my project structure to look like below.
Project Structure
— edgio.config.js
: Controls how your apps run on Edgio Applications. Also, here we define the origin/backend server.
— src/
— browser.ts
: Using this to install Edgio Applications’s Prefetcher in browser window
— cache.ts
: Solely used for maintaining caching configuration constants
— routes.ts
: Using this file to define what shall be cached, and for how long using the constants from cache.ts
— service-worker.ts
: Once installed, start prefetching content by predicting what’s user gonna tap at
— shoppingFlowRouteHandler.ts
: Using this to abstract the process in a single template to fetch the upstream response from the origin/backend server
— transform.ts
: Once the response is proxied, exploiting transformResponse function by Edgio Applications to inject Edgio specific JS to leverage prefetching, and make some front-end optimisations.
Now let’s do walk over on each of the files mentioned above.
edgio.config.js
In this file, I’ve defined two backends, www.nike.com as my origin, and static.nike.com as my assets backend. The idea is to proxy everything including assets and pages using both the backends.
// File: project-name/edgio.config.js
module.exports = {
routes: './src/routes.ts',
connector: '@edgio/starter',
backends: {
// Proxying origin
origin: {
domainOrIp: 'www.nike.com',
hostHeader: 'www.nike.com',
disableCheckCert: true,
},
// Proxying assets of origin
assets: {
domainOrIp: 'static.nike.com',
hostHeader: 'static.nike.com',
disableCheckCert: true,
},
},
}
browser.ts
Using install by @edgio/prefetch
module to install the service worker. I set includeCacheMisses
to true as that prefetch the response even if it’s not cached at the time of fetching.
// File: project-name/src/browser.ts
import { install } from '@edgio/prefetch/window'
// Install Edgio service worker when DOM content loads
document.addEventListener('DOMContentLoaded', function () {
// @ts-ignore
install({
// Don't want to wait for the cache to get warm
includeCacheMisses: true,
})
})
cache.ts
I cache the pages on the edge and in only the browser’s service worker for an hour. forcePrivateCaching: true
takes care of caching those pages where upstream returns a responseHeader of cache-contol: private, no-cache
. Similarly, I’m able to cache assets for over a day at the edge & the service worker.
// File: project-name/src/cache.ts
const ONE_HOUR = 60 * 60
const ONE_DAY = 24 * ONE_HOUR
// The default cache setting for pages in the shopping flow
export const CACHE_PAGES = {
edge: {
maxAgeSeconds: ONE_HOUR,
forcePrivateCaching: true,
},
browser: {
maxAgeSeconds: 0,
serviceWorkerSeconds: ONE_HOUR,
},
}
// The default cache setting for static assets like JS, CSS, and images.
export const CACHE_ASSETS = {
edge: {
maxAgeSeconds: ONE_DAY,
forcePrivateCaching: true,
},
browser: {
maxAgeSeconds: 0,
serviceWorkerSeconds: ONE_DAY,
},
}
shoppingFlowRouteHandler.ts
So assume that if a user comes looking for the homepage, then according to the configuration in routes.ts
, it calls the RouteHandler defined in this file, and then fetches the upstream on the same route. Then, the response headers of set-cookie
& content-security-policy
header are removed. Finally, the response is transformed as per the transformResponse function as defined in transform.ts
.
// File: project-name/src/shoppingFlowRouteHandler.ts
import { CACHE_PAGES } from './cache'
import transformResponse from './transform'
import { RouteHandler } from '@edgio/core/router/Router'
const handler: RouteHandler = async ({ cache, removeUpstreamResponseHeader, updateResponseHeader, setResponseHeader, proxy }) => {
cache(CACHE_PAGES)
removeUpstreamResponseHeader('set-cookie')
removeUpstreamResponseHeader('cache-control')
removeUpstreamResponseHeader('content-security-policy-report-only')
removeUpstreamResponseHeader('content-security-policy')
setResponseHeader('cache-control', 'public, max-age=86400')
updateResponseHeader('location', /https:\/\/www\.nike\.com\//gi, '/')
proxy('origin', { transformResponse })
}
export default handler
routes.ts
I define all the possible paths to be cached (with the help of configurations defined in cache.ts
), rest sent to the origin (the fallback).
An example of defining a route
Assume that on a website, an asset that has a relative url /l0-prodstatic/images/image/1.png
is being fetched. The route below will consider the variable :path*
to be images/image/1.png
. Then Edgio Applications would fetch the :path*
relative to the origin server as defined in the assets
keys of backends in edgio.config.js
. Once done, Edgio Applications would remove the set-cookie
header, update the cache-contol
response header and apply the cache timings.
.match('/l0-prodstatic/:path*', ({ cache, removeUpstreamResponseHeader, proxy, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
proxy('assets', { path: ':path*' })
})
The whole file
// File: project-name/src/routes.ts
import { CACHE_ASSETS } from './cache'
import { Router } from '@edgio/core/router'
import shoppingFlowRouteHandler from './shoppingFlowRouteHandler'
export default new Router()
// Edgio Applications Service Worker
.match('/service-worker.js', ({ cache, removeUpstreamResponseHeader, serveStatic, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
serveStatic('dist/service-worker.js')
})
// Edgio Applications Browser.js
.match('/__edgio__/:browser/browser.js', ({ cache, removeUpstreamResponseHeader, serveStatic, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
serveStatic('dist/browser.js')
})
// Homepage
.match('/', shoppingFlowRouteHandler)
.match('/:locale', shoppingFlowRouteHandler)
// PLP
.match('/w/mens-shoes:path', shoppingFlowRouteHandler)
.match('/:locale/w/mens-shoes:path', shoppingFlowRouteHandler)
// PDP
.match('/t/air-zoom:path/:suffix*', shoppingFlowRouteHandler)
.match('/:locale/air-zoom:path/:suffix*', shoppingFlowRouteHandler)
// Assets
.match('/static/:path*', ({ cache, removeUpstreamResponseHeader, proxy, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
proxy('origin')
})
.match('/assets/:path*', ({ cache, removeUpstreamResponseHeader, proxy, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
proxy('origin')
})
// Assets from static.nike.com being served frm l0-prodstatic as modified in the transform.ts
.match('/l0-prodstatic/:path*', ({ cache, removeUpstreamResponseHeader, proxy, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
proxy('assets', { path: ':path*' })
})
// If not found at any of above, but is an asset, cache it.
.match(
'/:path*/:file.:ext(js|mjs|css|png|ico|svg|jpg|jpeg|gif|ttf|woff|otf)',
({ cache, removeUpstreamResponseHeader, proxy, setResponseHeader }) => {
setResponseHeader('cache-control', 'public, max-age=86400')
removeUpstreamResponseHeader('set-cookie')
cache(CACHE_ASSETS)
proxy('origin')
}
)
// Everything else to origin
.fallback(({ proxy }) => {
proxy('origin')
})
service-worker.ts
I define all the possible paths to be cached (with the help of configurations defined in cache.ts
), rest sent to the origin (the fallback). Let’s look at this specific code from the whole file:
An example of defining what to be prefetched
Assume that I’m on the home page, and as soon as a link to my defined product listing page is being prefetched, apart from pre-fetching the HTML of that page, the service worker in the background will read what’s in the page, and identify those HTML elements that have an attribute of l0 set to true. Once identified, the callback function consumes the href attribute and starts prefetching those elements whether CSS, JS, Image, Asset, HTML, and stores it into the browser for future calls. Now you might wondering, how do we inject that? That’s addressed in the transform.ts, time to scroll through.
new Prefetcher({
plugins: [
new DeepFetchPlugin([
{
selector: '[l0="true"]',
maxMatches: 3,
attribute: 'href',
as: 'image',
callback: deepFetchImage,
},
])
]
})
.route()
.cache(/^https:\/\/(.*?)\.com\/.*/)
function deepFetchImage({ $el, el, $ }: DeepFetchCallbackParam) {
let urlTemplate= $(el).attr('href')
if (urlTemplate) {
prefetch(urlTemplate, 'image')
}
}
The whole file
// File: project-name/src/service-worker.ts
import { skipWaiting, clientsClaim } from 'workbox-core'
import { Prefetcher, prefetch } from '@edgio/prefetch/sw'
import DeepFetchPlugin, { DeepFetchCallbackParam } from '@edgio/prefetch/sw/DeepFetchPlugin'
skipWaiting()
clientsClaim()
new Prefetcher({
plugins: [
new DeepFetchPlugin([
{
selector: 'script',
maxMatches: 10,
attribute: 'src',
as: 'script',
callback: deepFetchJS,
},
{
selector: '[rel="stylesheet"]',
maxMatches: 10,
attribute: 'href',
as: 'style',
callback: deepFetchLinks,
},
{
selector: '[rel="preload"]',
maxMatches: 10,
attribute: 'href',
as: 'style',
callback: deepFetchLinks,
},
{
selector: '[l0="true"]',
maxMatches: 3,
attribute: 'href',
as: 'image',
callback: deepFetchImage,
},
]),
],
})
.route()
// Cache assets from static.nike.com once in the browser
.cache(/^https:\/\/static\.nike\.com\/.*/)
function deepFetchImage({ $el, el, $ }: DeepFetchCallbackParam) {
let urlTemplate = $(el).attr('href')
if (urlTemplate) {
prefetch(urlTemplate, 'image')
}
}
function deepFetchLinks({ $el, el, $ }: DeepFetchCallbackParam) {
let urlTemplate = $(el).attr('href')
if (urlTemplate) {
prefetch(urlTemplate, 'script')
}
}
function deepFetchJS({ $el, el, $ }: DeepFetchCallbackParam) {
let urlTemplate = $(el).attr('src')
if (urlTemplate) {
prefetch(urlTemplate, 'script')
}
}
transform.ts
The transformResponse function called by the shoppingFlowRouteHandler.ts
is used to modify the HTML response before sending it to the users. With the function injectBrowserScript
one is able to refer to the compiled browser.ts
. If there’s a response body, I assumed it had valid HTML, and then parsed it into a cheerio object. Then comes the part of front-end optimisation on the fly. Think of the approach in the implementation as serving pages optimised for performance, and then hydrating the page as required with JS. I applied lazy loading to every image on the page. Then I detect if the page is a PLP or PDP. In either case, I select the element that is critical to LCP, and then add the preload for it, as well as, remove lazy loading for that particular element.
// File: project-name/src/transform.ts
import cheerio from 'cheerio'
import Request from '@edgio/core/router/Request'
import Response from '@edgio/core/router/Response'
import { injectBrowserScript } from '@edgio/starter'
export default async function transformResponse(response: Response, request: Request) {
// inject browser.ts into the document returned from the origin
injectBrowserScript(response)
if (response.body) {
let $ = cheerio.load(response.body)
// cheerio.load(response.body)
console.log(`Transform script running on ${request.url}`)
// For production this script should be included in original website base code.
// <script defer src="/__edgio__/devtools/install.js"></script>
$('head').append(`
<script defer src="/__edgio__/cache-manifest.js"></script>
`)
// Load every other image lazily to avoid unnecessary initial loads on the page
$('img').each((i, el) => {
$(el).attr('loading', 'lazy')
})
// First image on PLP to load as soon as possible, preload for faster first load
if (request.path.includes('/w/')) {
$('.product-card__body noscript').each((i, el) => {
if (i < 1) {
let ele = $(el)
let hml = $(el).html()
if (ele && hml) {
let img = cheerio.load(hml)
$('.product-card__body img').first().removeAttr('loading')
$('.product-card__body img').first().attr('src', img('img').attr('src'))
// Preload image, and add an attribute to ensure easy prefetch
$('head').prepend(`<link l0="true" rel="preload" as="image" href="${img('img').attr('src')}" />`)
}
}
})
}
// First image on PDP to load as soon as possible, preload for faster first load
if (request.path.includes('/t/')) {
let img = ''
$('img.u-full-height').each((i, el) => {
if (i == 1) {
img = $(el).attr('src') || ''
// Preload image, and add an attribute to ensure easy prefetch
$('head').prepend(`<link l0="true" rel="preload" as="image" href="${img}" />`)
}
})
$('img.u-full-height').each((i, el) => {
if (i == 0) {
$(el).removeAttr('loading')
$(el).removeAttr('data-fade-in')
$(el).attr('src', img)
}
})
}
response.body = $.html()
// Replace display: none; with {}
.replace(/\{ display\: none\; \}/g, '{}')
// Replace opacity: 0; with nothing
.replace(/\opacity\: 0\;/g, '')
// Replace =" with ="https://
.replace(/\=\"\/\//g, '="https://')
// Replace all https://www.nike.com with /
.replace(/https:\/\/www\.nike\.com\//g, '/')
// Replace all https://static.nike.com with /l0-prodstatic/
.replace(/https:\/\/static\.nike\.com\//g, '/l0-prodstatic/')
// Replace all ?edgio_dt_pdf=1 with nothing
.replace(/\?edgio\_dt\_pf\=1/g, '')
}
}
We’re done! Let’s deploy fearlessly now.
Emulating the production experience, locally
With Edgio Applications’s CLI, it is possible to emulate edge rules locally as if the code went live. Here’s how I did it:
edgio build && edgio run --production
Deploy
Deploying from CLI can be done as mentioned in Edgio Applications Docs
edgio deploy
Results
If you’ve come this far (awesome!) you probably want to know the result. Tbh, I was surprised with the results too. Upto 73% improvement on first page loads & 80% improvement on navigation!
PLP First Load, Improvement ~73%
<img src="https://webpagetest.org/result/220206_AiDcZN_CY3/" alt="(PLP First Load, LCP: Nike.com: 4.8s" />, Optimised [with Edgio: 1.3s](https://webpagetest.org/result/220206AiDcQ6CY6/), Improvement: ~73%)
(PLP First Load, LCP: Nike.com: 4.8s, Optimised with Edgio: 1.3s, Improvement: ~73%)
PDP First Load, Improvement ~55.55%
<img src="https://webpagetest.org/result/220206_AiDcS7_CYC/" alt="(PDP First Load, LCP: Nike.com: 4.5s" />, Optimised [with Edgio: 1.9s](https://webpagetest.org/result/220206AiDcXRCYE/), Improvement: ~55.55%)
(PDP First Load, LCP: Nike.com: 4.5s, Optimised with Edgio: 1.9s, Improvement: ~55.55%)
Home to PLP Navigation, Improvement ~80%
(Home to PLP Navigation, LCP: Nike.com: 5.4s, Optimised with Edgio: 1.1s, Improvement: ~80%)
PLP to PDP Navigation, Improvement ~60%
(PLP to PDP Navigation, LCP: Nike.com: 2.7s, Optimised with Edgio: 1.1s, Improvement: ~60%)
Video: Home to PLP Navigation Comparison
Home - PLP Navigation Experience Comparison Recording via WebPageTest, 5.4s on Nike.com vs 1.1s on Nike.com demo on Edgio
Discussion
My implementation aggressively focuses on optimising what I set out for testing: First Page Loads & LCP. I do that with Edgio Applications’s Caching of more than just assets, and Edgio Applications’s prefetching to serve users as if the whole website in running on their browser itself. I also did some optimisation in the frontend to prioritise the assets and page load. Definitely, this approach is not set in stone, but seems like nike.com can deliver a way better online shopping experience by utilising caching and prefetching.