Sunday, January 1 2023

Case Study: How Nginx.com can leverage Edgio to improve their First Page Loads upto ~50%, acing Largest Contentful Paint.

Rishi Raj Jain
Rishi Raj Jain @rishi_raj_jain_

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 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. In this study, I set out to improve www.nginx.com! by caching their home page (/) and product listing page (/solutions/). I aimed for at least 50% boost in the performance.

Currently, https://www.nginx.com is failing Core Web Vitals badly.

Core Web Vitals of www.nginx.com

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.nginx.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.

Network Diagram

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/routes.ts: Using this file to define what shall be cached, and for how long.

src/shoppingFlowRouteHandler.ts: Using this to abstract the process in a single template to fetch the upstream response from the origin/backend server

src/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 one backend, www.nginx.com as my origin. The idea is to proxy everything including assets and pages using the backend.

// File: improve-www.nginx.com/edgio.config.js

module.exports = {
  routes: './src/routes.ts',
  connector: './node_modules',
  backends: {
    origin: {
      domainOrIp: 'www.nginx.com',
      hostHeader: 'www.nginx.com',
      disableCheckCert: true,
    },
  },
}

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: improve-www.nginx.com/src/shoppingFlowRouteHandler.ts

import transformResponse from './transform'
import { RouteHandler } from '@edgio/core/router/Router'

const handler: RouteHandler = async ({ cache, removeUpstreamResponseHeader, updateResponseHeader, proxy }) => {
  cache({
    edge: {
      maxAgeSeconds: 60,
      staleWhileRevalidateSeconds: 60 * 60 * 24 * 365,
    },
  })
  removeUpstreamResponseHeader('set-cookie')
  removeUpstreamResponseHeader('cache-control')
  updateResponseHeader('location', /https:\/\/nginx\.com\//gi, '/')
  updateResponseHeader('location', /https:\/\/www\.nginx\.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('origin', { path: ':path*' })
})

The whole file

// File: improve-www.nginx.com/src/routes.ts

import { Router } from '@edgio/core/router'
import shoppingFlowRouteHandler from './shoppingFlowRouteHandler'

export default new Router()
  .match('/', shoppingFlowRouteHandler)
  .match('/:suffix', shoppingFlowRouteHandler)
  .match('/wp-includes/:path*', ({ cache, proxy, removeUpstreamResponseHeader }) => {
    removeUpstreamResponseHeader('set-cookie')
    cache({
      edge: {
        maxAgeSeconds: 60 * 60 * 24 * 365,
      },
    })
    proxy('origin')
  })
  .match('/wp-content/:path*', ({ cache, proxy, removeUpstreamResponseHeader }) => {
    removeUpstreamResponseHeader('set-cookie')
    cache({
      edge: {
        maxAgeSeconds: 60 * 60 * 24 * 365,
      },
    })
    proxy('origin')
  })
  .match(
    '/:path*/:file.:ext(js|mjs|css|png|ico|svg|jpg|jpeg|gif|ttf|woff|otf)',
    ({ cache, proxy, removeUpstreamResponseHeader, setResponseHeader }) => {
      setResponseHeader('cache-control', 'public, max-age=86400')
      removeUpstreamResponseHeader('set-cookie')
      cache({
        edge: {
          maxAgeSeconds: 60 * 60 * 24 * 365,
        },
      })
      proxy('origin')
    }
  )
  .fallback(({ proxy }) => {
    proxy('origin')
  })

transform.ts

The transformResponse function called by the shoppingFlowRouteHandler.ts is used to modify the HTML response before sending it to the users. 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.

// File: improve-www.nginx.com/src/transform.ts

import * as cheerio from 'cheerio'
import Request from '@edgio/core/router/Request'
import Response from '@edgio/core/router/Response'

export default function transformResponse(response: Response, request: Request) {
  if (response.body) {
    // Load the HTML into cheerio
    const $ = cheerio.load(response.body)
    // Only optimise for / and /ltl/
    if (request.path == '/' || request.path.includes('/solutions')) {
      // Remove dns-prefetch(es) on the page
      $('[rel="dns-prefetch"]').remove()
      let bodyScripts = $('body script')
      // As the page's style and look
      // isn't affected by JavaScript
      // Move the scripts away from head to the
      // end of the body
      $('head script').each((_, ele) => {
        let temp = $(ele)
        $(ele).remove()
        $('body').append(temp)
      })
      // As we moved away the head scripts
      // We'd also need to move the body scripts
      // after them so that they can load the
      // required libraries first
      bodyScripts.each((_, ele) => {
        let temp = $(ele)
        $(ele).remove()
        $('body').append(temp)
      })
      // Load google fonts without them
      // blocking the rendering completion
      $('head link[href*="googleapis"]').each((i, el) => {
        let temp = $(el)
        temp.attr('media', 'print')
        temp.attr('onload', "this.media='all'")
      })
      // As the page's style isn't affected
      // by their font awesome css,
      // defer the style's load
      $('head link[href*="font-awesome.min.css"]').each((i, el) => {
        let temp = $(el)
        temp.attr('media', 'print')
        temp.attr('onload', "this.media='all'")
      })
    }
    response.body = $.html()
      // Replace =" with ="https://
      .replace(/\=\"\/\//g, '="https://')
      // Replace all https://www.nginx.com with /
      .replace(/https?:\/\/www\.nginx\.com\//g, '/')
      // Replace all https://nginx.com with /
      .replace(/https?:\/\/nginx\.com\//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 56% improvement on first page loads & 80% improvement on navigation!

A quick look at TTFB of both the sites around the world

SpeedVitals of www.nginx.com SpeedVitals of www.nginx.com

SpeedVitals of rishi-raj-jain-nginx-default.layer0-limelight.link SpeedVitals of rishi-raj-jain-nginx-default.layer0-limelight.link

Home First Load, Improvement ~50%

Home First Load, LCP: www.nginx.com: 4.203s, Optimised with Edgio: 2.107s, Improvement: ~50%)

PLP First Load, Improvement ~50%

(PLP First Load, LCP: www.nginx.com: 4.338s, Optimised with Edgio: 2.170s, Improvement: ~50%)

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 browser prefetching. 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 www.nginx.com can deliver a way better online shopping experience by utilising caching and prefetching.

Code

https://github.com/rishi-raj-jain/improve-www.nginx.com

Write a comment

Email will remain confidential.