Improving load performance on Ghost with a CDN

To avoid doing chores, I nerd-sniped myself into figuring out an easy way to make the Tryst Blog (potentially NSFW) which runs on Ghost use a CDN for the images.

Normally, you could use a CDN service that is designed to sit in front of an entire application like Cloudflare, but we don't use Cloudflare for Assembly Four for being booted without notice.

The Problem

There's not a lot of CDNs (that we can use) that are designed to handle being put it front of a web application.

We've recently deployed our own edge network around the globe for performance and redundancy. The edge nodes handle TLS termination, keep upstream connections open, and provide a basic caching layer which significantly improved our performance.

However, there are limitations. If we want to handle functionality like automatically serving WebP, this introduces a lot more complexity into our edge stack.

The simplest way to improve performance on our blog was to ensure that images are served by a CDN that is capable of this stuff. We need to change the image URLs returned by Ghost so they point to the CDN.

Ghost does not provide a feature to let you set a CDN for images, which seems rather odd to me, but I suspect it has to do with their hosted offering.

Our starting point is a PageSpeed score of 87 on mobile.

Mobile Pagespeed result before putting images in front of CDN.

Most of our penalties come from Speed Index and LCP, as the images aren't really optimised and have to be sent from our origin in Europe to the US.

Image load timings through edge network, no caching.

First attempt

We can leverage the basic caching ability of our edge network to cache the images at our edge.

After creating a rule that caches the images on the edge, our image load timings went from around 1000ms to about 250ms, which is a significant improvement for not that much work! That is, if you don't include the work to set up your own edge network.. which took me about a week's worth of work.

Our resulting Pagespeed score is now 90 on mobile.

However, our Speed Index could still be improved by serving modern image formats with better compression.

Serving images in next-gen formats is estimated to save 2.25s.

Using a CDN to serve optimised images

Rather than complicate our Ghost stack too much, I went for the option of using a CDN that handles image optimisation for us.

Once I verified that the CDN'd endpoint worked correctly, the problem became making Ghost use the domain for serving images.

My first few attempts at this had me going down a rabbit hole with lua-nginx-module, but turns out openresty has subs_filter built in, which provides more features compared to the stock sub_filter module.

 # Ensure the response is in plain text otherwise subs_filter won't work
proxy_set_header Accept-Encoding '';

# First pass
subs_filter /blog/content/images https://blog.tryst.a4cdn.ch/blog/content/images g;

# Handle cases where Ghost was already using a full URL
subs_filter https://tryst.linkhttps https g;
nginx configuration to serve images from another domain.

After implementing this, our mobile Pagespeed went to 96.

Mobile Pagespeed score is now 96 after using WebP for images.

By transmitting less data, our image load time has dropped from 250ms to 100ms!

Image load times have dropped to around 100ms.

Bonus: Native lazy loading

Being able to modify the response body also lets us enable native image lazy loading for browsers that do support it.

subs_filter <img "<img loading=\"lazy\"" g;

However, this does not appear to make a difference to the Pagespeed score.

I hope this is useful!