00%
blog.info()
← Back to Home
SEQUENCE // Building The Blog

Cutting Page Load Times by 50%

Author Thorn Hall
0

I wanted my blog to look snappier when someone visits it, especially for the first time. First impressions are very important.

Here's how I cut my page load time from 550ms -> 300ms for first time visitors and from 400ms -> 100ms for returning users.

Diagnosis

On the web, often times latency is caused by the time spent sending data over the network, rather than the time rendering the page or the time running JavaScript. So naturally, my first step was curl my app to see how much data it's sending over the network to a new user.

1➜  blog git:(main) ✗ curl -so /dev/null "https://thorn.sh/" -w "Bytes Transferred: %{size_download}\n\n"
2Bytes Transferred: 148765

148,000 bytes is 148kb. This really is already quite good, but I was curious to see how things were playing out in the browser.

So I opened the Network tab in Chrome, and it showed a page load time of 500ms. Not bad, but it could be better. After all, my blog is mostly just HTML and CSS with a little bit of JavaScript.

Optimizing the Network

Given this data, I decided it made sense to proceed with optimizing the amount of data my server is sending over the network. I needed to make the files smaller.

To do this, I used compression. Compression is how we take some data we want to send and make it much, much smaller so it's easier to send over the network. The client decompresses it back into its original form.

To accomplish compressing all of the files served, I followed two steps:

  • First, during the build step, my builder generates compressed versions of every publically accessible file, including .html files.
  • Then, my router middleware tells the browser that it's sending compressed data.

Here's what my middleware looks like:

 1func WithCompression(root string, next http.Handler) http.Handler {
 2	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 3		acceptEncoding := r.Header.Get("Accept-Encoding")
 4		acceptsBrotli := strings.Contains(acceptEncoding, "br")
 5		acceptsGzip := strings.Contains(acceptEncoding, "gzip")
 6
 7		path := r.URL.Path
 8		if path == "/" {
 9			path = "/index.html"
10		}
11
12		fullPath := filepath.Join(root, filepath.FromSlash(path))
13
14		info, err := os.Stat(fullPath)
15		if err == nil && info.IsDir() {
16			if !strings.HasSuffix(r.URL.Path, "/") {
17				// Redirect to add trailing slash
18				http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
19				return
20			}
21			fullPath = filepath.Join(fullPath, "index.html")
22		}
23
24		if strings.HasPrefix(r.URL.Path, "/assets/") || strings.HasPrefix(r.URL.Path, "/static/") {
25			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
26		}
27
28		tryServe := func(ext, encoding string) bool {
29			compressedPath := fullPath + ext
30			// Check the compressed file exists and attempt to serve it 
31			if _, err := os.Stat(compressedPath); err == nil {
32				originalExt := filepath.Ext(fullPath)
33				ctype := mime.TypeByExtension(originalExt)
34
35				if ctype == "" {
36					ctype = "application/octet-stream"
37				}
38
39				w.Header().Set("Content-Encoding", encoding)
40				w.Header().Set("Content-Type", ctype)
41				http.ServeFile(w, r, compressedPath)
42				return true
43			}
44			return false
45		}
46
47		if acceptsBrotli && tryServe(".br", "br") {
48			return
49		}
50
51		if acceptsGzip && tryServe(".gz", "gzip") {
52			return
53		}
54
55		// Fallback to serve un-compressed
56		next.ServeHTTP(w, r)
57	})
58}

This middleware is basically telling the file server "if you have a compressed file, serve it and tell the browser that it's compressed."

After applying the compression middleware, I observed the total amount of bytes sent again:

1➜  blog git:(main) ✗ curl -so /dev/null -H "Accept-Encoding: br" "http://localhost:8080/" -w "Bytes Transferred: %{size_download}\n\n"
2
3Bytes Transferred: 13585

I optimized the data sent from 148kb to a tiny 13kb!

Further Optimization

After deploying this change to the browser, I noticed load times still were around the 500ms range. This was a little confusing.

Upon further investigation, I noticed that the fonts on my page, which are fetched from Google, were taking 300ms to load on their own.

Pre-building Fonts

To fix this issue, I added a new step to my build phase. Instead of the blog requesting the font from Google servers on page load, I did the following:

  • During the build phase, we fetch the font from the URL directly.
  • We then compress and serve that font on our own endpoint.
  • We modify the HTML of the blog to fetch the now compressed font from our own endpoint.

After doing this, I got the page load to around 400ms. I wasn't ready to stop there though.

I looked closer at the network waterfall and found a massive bottleneck. My profile picture was 1.3MB. The browser was downloading a high-resolution image just to display it in a small circle. This was delaying the final load event significantly.

I added an image processing step to my builder using the imaging library. Now, the builder resizes the source image to 300 pixels and saves it as a compressed JPEG before the site is published. This reduced the file size from 1.3MB to 15kb.

The WASM Waterfall

There was one final delay. The Game of Life simulation on the homepage relies on a WebAssembly binary. The browser was waiting to download the loader script, executing it, and only then requesting the .wasm file. This created a sequential delay.

To fix this, I added a preload link to the HTML header. This tells the browser to fetch the binary immediately in parallel with the CSS and JavaScript.

1<link rel="preload" href="/static/life1.wasm" as="fetch" type="application/wasm" crossorigin>

I also updated my builder to include the .wasm extension in the compression loop, reducing the binary size by nearly 70%.

Results

After these changes, the network usage in Chrome dropped from 1.3MB down to 200KB. The raw data went from 148kb to 13kb.

The cold load time dropped to 300ms. Because of the aggressive caching headers I added to the middleware, returning visitors see the page in about 100ms.

The site now feels nearly instant for returning users, while still being incredibly snappy for first time visitors.

View Abstract Syntax Tree (Build-Time Generated)
Document
Paragraph
Text "I wanted my blog to look sn..."
Text " important."
Paragraph
Text "Here's how I cut my page lo..."
Text " users."
Heading
Text "Diagnosis"
Paragraph
Text "On the web, often times lat..."
CodeSpan
Text "curl"
Text " my app to see how much dat..."
Text " user."
FencedCodeBlock code: "➜ blog git:(ma..."
Paragraph
Text "148,000 bytes is 148kb. Thi..."
Text " browser."
Paragraph
Text "So I opened the Network tab..."
CodeSpan
Text "500ms"
Text ". Not bad, but it could be ..."
Text " is"
Text "mostly just HTML and CSS wi..."
Text " JavaScript."
Heading
Text "Optimizing the"
Text " Network"
Paragraph
Text "Given this data, I decided ..."
Text " smaller."
Paragraph
Text "To do this, I used "
CodeSpan
Text "compression"
Text ". Compression is how we tak..."
Text " form."
Paragraph
Text "To accomplish compressing a..."
Text " steps:"
List
ListItem
TextBlock
Text "First, during the build ste..."
Text " files."
ListItem
TextBlock
Text "Then, my router middleware ..."
Text " data."
Paragraph
Text "Here's what my middleware l..."
Text " like:"
FencedCodeBlock code: "func WithCompress..."
Paragraph
Text "This middleware is basicall..."
Text " compressed.""
Paragraph
Text "After applying the compress..."
Text " again:"
FencedCodeBlock code: "➜ blog git:(ma..."
Paragraph
Text "I optimized the data sent f..."
Text "!"
Heading
Text "Further"
Text " Optimization"
Paragraph
Text "After deploying this change..."
Text " confusing."
Paragraph
Text "Upon further investigation,..."
Text " own."
Heading
Text "Pre-building"
Text " Fonts"
Paragraph
Text "To fix this issue, I added ..."
Text " following:"
List
ListItem
TextBlock
Text "During the build phase, we ..."
Text " directly."
ListItem
TextBlock
Text "We then compress and serve ..."
Text " endpoint."
ListItem
TextBlock
Text "We modify the HTML of the b..."
Text " endpoint."
Paragraph
Text "After doing this, I got the..."
Text " though."
Paragraph
Text "I looked closer at the netw..."
Text " significantly."
Paragraph
Text "I added an image processing..."
Text " 15kb."
Heading
Text "The WASM"
Text " Waterfall"
Paragraph
Text "There was one final delay. ..."
Text " delay."
Paragraph
Text "To fix this, I added a prel..."
Text " JavaScript."
FencedCodeBlock code: "<link rel="preloa..."
Paragraph
Text "I also updated my builder t..."
Text " 70%."
Heading
Text "Results"
Paragraph
Text "After these changes, the ne..."
Text " 13kb."
Paragraph
Text "The cold load time dropped ..."
Text " 100ms."
Paragraph
Text "The site now feels nearly i..."
Text " visitors."