Make your Ghost blog super fast

Learn how to improve the speed, performance and Page Experience of your Ghost blog and achieve a fast user loading and navigation.

Make your Ghost blog super fast

Let's examine what we can do to improve the already excellent performance features of a Ghost blog and make it super fast, improving page experience and Search Engine Optimization in the process.

(Photo by Johannes Andersson )

Speed is an important factor for user experience for any site or mobile application: multiple organizations have been studying at the effects of poor performance and at ways to improve it. More recently, Google announced the upcoming changes in terms of Page Experience ratings for  websites. Page Experience will be used as an input to the ranking signals that determine whether your website or page is shown in search results.

In this article, I will explain how I have reviewed the performance of this site using Google PageSpeed Insights / Measure and Lighthouse, and acted on some of the recommendations.

Let's start from the outcome! Here is what it looks like right now using Measure after the updates I've made - and keeping in mind that these result may fluctuate over multiple audits.

Audit for homepage 😎
Audit for homepage 😎
Audit for a sample text and image blog post (TLS 1.2 overview) 😅
Audit for a sample text and image blog post (TLS 1.2 overview) 😅
Audit for a blog post with third party scripts (Youtube / Twitter embeds) 😱
Audit for a blog post with third-party scripts (YouTube / Twitter embeds) 😱

There is still work to do to improve, but I have discovered a few easy tricks to speed up most of my pages. In terms of findings, I would say that the main areas of improvement that were found straight away are:

  • Image sizing / compression / deferred loading.
  • 3rd party scripts.
  • A few accessibility tweaks, for example: remember to add alternative image descriptions to the images when authoring a blog post. This is supported in Ghost but optional so you have to remind to do it.

I have also discovered that I need to be very wary of these fancy YouTube video and Twitter card embeds that seem to absolutely be killing the performance of the pages as seen in the last example.

Finally - it was very pleasing to see that the solid foundations put in place in the past were already putting me ahead of the curve. If you are interested, have a look at some of my older articles:

Let's now look at the things I did in this round of tweaks.

Image Optimization

One of the very first obvious things that wereflagged was the fact that the images, especially on the homepage were not compressed optimally or served using one of the best formats available.

Turns out the easiest way to achieve this is by using a CDN that automatically optimize the format of the picture. This is done depending on the capabilities of the browser connected. When possible, it is best to take advantage of modern compression image formats such as  WebP, Jpeg2000 or JPEG XR.

If you use Cloudflare, you can use the Polish service which will automatically take care of this for you. This is part of the paid Pro offering which comes with other goodies such as Enhanced WAF (Web Application Firewall), caching analytics and other mobile optimizations. Polish is a a turn-key feature which will seamlessly inject the image optimizations to all traffic served on your configured DNS Zone and it is very easy to set up.

Otherwise, you can look at an alternative available for free, especially if you have a small personal blog. Ghost can integrate with Cloudinary. Cloudinary is a media optimisation platform and they have a free plan which can work for you, depending on the amount of traffic you get.

In my case, I wanted to try the Cloudinary option without using the storage. In other words, I want the images to be uploaded and stored on my server, but I then want to use the Cloudinary fetch API to automatically pull an optimised version. To do this, I changed the code of my version of Casper (learn more about that in this article) and followed the Ghost tutorial. Here's a sample of my change from post-cards.hbs

<img class="post-card-image"
    srcset="<YOUR_USERNAME>/image/fetch/w_300,c_fit,q_auto,f_auto,dpr_auto/{{img_url feature_image size="s" absolute="true"}} 300w,
  <YOUR_USERNAME>/image/fetch/w_600,c_fit,q_auto,f_auto,dpr_auto/{{img_url feature_image size="m" absolute="true"}} 600w,
  <YOUR_USERNAME>/image/fetch/w_1000,c_fit,q_auto,f_auto,dpr_auto/{{img_url feature_image size="l"absolute="true"}} 1000w,
  <YOUR_USERNAME>/image/fetch/w_2000,c_fit,q_auto,f_auto,dpr_auto/{{img_url feature_image size="xl"absolute="true"}} 2000w"
    sizes="(max-width: 1000px) 400px, 700px"
    src="<YOUR_USERNAME>/image/fetch/w_600,c_fit,q_auto,f_auto,dpr_auto/{{img_url feature_image size="m" absolute="true"}}"

Summarizing: the image will be loaded from Cloudinary and certain transformations will be applied using the Cloudinary image fetch API:

  • w_XXX: this will resize the width of the to the value specified XXX
  • c_fit: ensure the image stays within the bounding box provided. In our case we only provided a width so this should not be required
  • q_auto , f_auto , dpr_auto: let Cloudinary decide the best quality, format and device pixel ratio depending on the connecting device.

You can see the below files for more details of the changes done:

GitHub Gist: instantly share code, notes, and snippets.
Changes to post-cards.hbs
GitHub Gist: instantly share code, notes, and snippets.
Changes to header-background.hbs
GitHub Gist: instantly share code, notes, and snippets.
feature-image.hbs , a new partial I added and used in post.hbs and page.hbs

Please note: this change will not cover images that are part of the blog post content, as this is not exposed in the theme.

A different approach should be taken here. For example, we could rewrite the HTML on the fly with a Cloudflare Worker. We will look at this again in the next step where we introduce it.

Lazy Loading

The inspiration for this tweak came from this blog post. In short, while the Ghost team is busy adding native support for this in the platform itself, we can implement lazy loading ourselves via a Cloudflare worker.

This worker will intercept all requests on the blog domain, and look for image tag elements. If the img tags found do not have a loading="lazy" attribute, it will add it to them, then serve the updated HTML document to the browser.

This ensures that images which are not yet visible to the user are not loaded immediately, greatly speeding up the page! You can find out more about the feature that is now available in most major browsers.

There is one aspect, that the worker code included by the aforementioned blog author is very wide-ranging: the worker operates on every request hitting the blog, and then inspects and modifies any img tag found.

This has caused a problem to me while I was authoring this blog post - specifically, it messed up the Ghost Admin API as it tried to return the post contents in JSON for the above post-cards.hbs code snippet.  The worker stepped in, including the tag and breaking the JSON escaping, ultimately causing an HTTP 500 error in the editor.

I have disabled the worker, then modified the original code to look like this

addEventListener('fetch', event => {

const EXCLUDED_PATHS = ["/ghost","/rss","/content","/assets"]
const IMG_SELECTORS = ["kg-image", "post-card-image"]

class ElementHandler {
  element(element) {

      const imgClass = element.getAttribute('class') || ''
      const imgSrc = element.getAttribute('src') || ''

      if (IMG_SELECTORS.some( val => imgClass.includes(val) )) {

        // Add lazy loading if not defined already
        if(!element.getAttribute('loading')  ) {
          element.setAttribute('loading', 'lazy')

        // If image is loaded from local content, rewrite to pull from CDN
        if(imgSrc.startsWith(ENV_LOCAL_CONTENT_URL)) {


async function handleRequest(event) {
  const pathname = new URL(event.request.url).pathname.toLowerCase()
  const res = await fetch(event.request)
  if (EXCLUDED_PATHS.some( val => pathname.startsWith(val) )) { 
    return res
  } else { 
    return new HTMLRewriter().on('img', new ElementHandler()).transform(res)
Updated worker code - Github

There is quite a lot going on here so let's dissect this further:

  • The handleRequest function has been changed, it now looks at the request url and determines whether we should run the main worker logic or bypass it depending on the context path value. EXCLUDED_PATHS is an array containing specific Ghost paths we want to bypass: /ghost , /rss , /content, /assets. We bypass them either because we don't want the substitution to happen at all, or because we already know it will not be needed for these requests. For example, a request for a static asset does not need any HTML rewriting.
  • Similarly, the ElementHandler now looks at the element and inspects both the src and class attributes. We only want the worker to operate if the image class is one of the list. Please note that I added feature-image myself in the partial I created earlier feature-image.hbs - so that I can target post feature images. Update 19 July 2020: I have reverted the feature-image customization as it was causing CLS (Cumulative Layout Shift) - as reported by the Google Webmaster Search Tools.
  • If the image is one of the ones we want to operate on, then we add the loading=lazy attribute if not already defined (same as in Stanislas's original code).
  • I added another change so that we can load our post images from Cloudinary as well. Remember that these were out of reach from our earlier change? With the Cloudflare worker, I can check if the image URL starts with a specific value, and if so I can prepend the Cloudinary fetch URL to it. Pretty neat! However this won't work if the image has a srcset defined, as I am not rewriting that for the time being. Maybe as a future enhancement.

If you are wondering what ENV_LOCAL_CONTENT_URL and ENV_CDN_FETCH_URL are, these are Cloudflare Worker Environment Variables. In other words, the code will be able to resolve them when running. This allows the worker code to be more configurable: you can set the variables either in the Cloudflare UI or (recommended) using wrangler.

Example setting an environment variable in the Cloudflare Workers Settings
Example setting an environment variable in the Cloudflare Workers Settings

For further information, I have deployed the worker code in Github - feel free to review it there with additional details.

🔥 Update 28 August 2020: I have created a separate worker script which works with Ghost without further configuration and is specifically meant to be used with Ghost and Cloudflare's own Image Resizing capability. You can find the worker here.

You can also auto deploy it by using the below button. Refer to the earlier link for setting up the worker route to activate it.

Deploy to Cloudflare Workers

External connection hints

One of the suggestions from Lighthouse was around the fact that the site was not using any preloading / external connection hints for the browser. For example, the Casper theme uses jQuery and it loads it from

Turns out there is a way we can tell the browser that the document will need to load resources from a certain URL. In my case, all I needed to do was to add the following tags in the Code Injection tab in Ghost Admin:

<link rel="preconnect" href="">
<link rel="preconnect" href="">

In this way, the browser can warm up the handshake with these external domains: as you can imagine, the browser will need to set up an encrypted TLS connection to get the data it needs. The tag above gives it the chance to start doing some of that work before it knows what it needs to download exactly.

I recommend reading the following blog article if you want to understand more about preloading / prefetching and other types of browser hints.

Lazy Loading Commento

As you may know, this blog is using Commento as a commenting system. I wrote an article on how to self-host and get up and running with it. As I spent time figuring out image lazy loading, I wondered whether I should do the same with Commento which after all is only used once someone has scrolled down to the end of the article.

Turns out someone else had already thought about that and solved it: you can see the solution in this blog post, which is related to another blogging platform but works perfectly well with Ghost too!

Falcon Heavy lift-off - Photo by Bill Jelen
Photo by Bill Jelen

Next Steps

I still need to figure out how to shave off further delays for pages that include third party integrations and widgets. It's almost comical that Page Speed Insights is telling me that a Youtube embed is killing the performance of my beloved blog article 🤣

I hope that the above tips and tricks will help you in setting up a super-fast page, regardless of whether you are using Ghost or another system. Some tips are trivial to roll out and will make a difference.

Let me know in the comments below if you have any other insights or tricks to share with me, and thanks for reading!

If you liked this article, follow me on Twitter for more updates!