How to over-engineer a static page
When we set out to rebuild our own website simplabs.com in 2019, we wanted to use that project as an opportunity to ignore all economic considerations (and reason you could say) and dive deep into what was technically possible. Doing so would allow us to build something that was super customized for our specific needs and highly optimized for performance. We ended up spending a lot of time and effort but are quite pleased with the result.
While we cannot recommend anyone following our example as your time is most likely better spent elsewhere, this post explains the approach we took. I will be covering topics like static pre-rendering and client-side rehydration, advanced bundling and caching strategies as well as service workers.
anchorOne Project, many Goals
Our goals for the new website were manifold:
- We wanted to update the rather antiquated design of the old site and create a modern design language that represented our identity well and was build on a design system that we could build on in the future (the details of which are to be covered in a separate, future post).
- We wanted to keep creating and maintaining content for the site as simple as it was with the previous site that was built on Jekyll and published via GitHub Pages; for example adding new blog posts should remain as easy as adding a new Markdown file with some front matter.
- Although we are huge fans of Elixir and Phoenix we did not want to create an API server for the site that would serve content etc. as that would have added quite some unnecessary complexity and should generally not be necessary for a static site like ours anyway where all content is known upfront and nothing ever needs to be calculated dynamically on the server.
anchorStatic Prerendering + Rehydration and CSR
Since we are huge fans of Ember.js and are heavily connected with the community, supporting it and even directly involved in its core team, we wanted to stay in the ecosystem to build our own site as well. While Ember.js is a great fit for ambitious apps like travel booking systems or appointment scheduling systems that implement significant client side logic though, for a static page like ours it would admittedly not have been an ideal fit – we would simply not have needed or used much of what it comes with. Ember.js' lightweight sister project Glimmer.js provides exactly what we need though, which is a system for defining and rendering components and trees of components that would get re-rendered upon changes to the application state.
Statically pre-rendering a client-side app at build time is relatively straight forward. As part of our Netlify deployment, we build the app and start a small Express Server that serves it. We then visit each of the routes with a headless instance of Chrome using Puppeteer, take a snapshot of the page's DOM and save that to a respectively named file. All of these HTML files, along with the app itself and all other assets, then get uploaded to the CDN to be served from there.
Although we were switching to a significantly more advanced setup than what we had with the previous Jekyll-based site, we did not want to give up the easy maintenance of content, specifically for blog posts and similar content that we wanted to keep in Markdown files as we used to. Writing a new post should remain as easy as adding a new markdown file with some front matter and Markdown-formatted content. At the same time, we did not want to rely on an API for loading the content of particular pages dynamically as that would have added significant additional complexity and none of our data actually needed to be computed on demand on the server as all of it is indeed static and known upfront. Leveraging Glimmer.js' Broccoli-based build pipeline, we set up a process that reads in all files in a directory and converts the Markdown files into Glimmer.js components at build time.
That way we are generating dedicated components for all posts that are all mapped to their own routes. We also generate the components for the blog listing page(s) and the ones that list all posts by a particular author. This approach basically moves what would typically be done by an API server at runtime (retrieving content from a repository that grows and changes over time) to build time, much like what the Jamstack advocates for (and tools like Empress, VuePress or Gatsby would have done out of the box 😀). The same approach is used for other parts of the website that grow and change over time and that we want to be able to maintain content for with little effort like the calendar or talks catalog.
anchorBundling and Caching
- the main bundle that contains Glimmer.js itself as well as all of the main site's content; that is about 70KB as of the writing of this post
- the bundles for each of the blog's listing pages as well as individual bundles for each post; these are relatively small but change frequently of course
- additional bundles for more frequently changing content like the calendar or talks catalog
- a bundle that contains a component that lists the most recent blog posts for a particular topic that; that component gets included on pages within the main site
anchorBundles and Caching
Another factor to take into account when defining bundle boundaries is the stability of each bundle in the sense of how often it is going to change over time. Our main bundle that contains Glimmer.js and the site's main content is relatively stable and will typically not change for longer periods of time (potentially weeks or months). That means once it is cached in a user's browser, there is a good chance they will be able to reuse it from cache upon their next visit. If we had included all of the components for all of the blog posts in that main bundle though, we would not only have steadily grown that bundle over time but also invalidated the users's cache for it every time we released a new post. The same is true for the component that renders a list of recent posts for a particular topic. As that component is always needed along with components that are part of the main bundle as it is rendered on the respective pages, we could have included it right with the main bundle, but that would likewise have meant invalidating the main bundle with every blog post which would have resulted in a poor utilisation of our user's caches.
As described, we optimized our bundles for cache-ability. Since we also use fingerprinted asset names (or actually get them for free out of the box since Glimmer.js uses Ember CLI), we can let our user's browsers cache all resources indefinitely using immutable caching:
immutable caching directive tells the browser that the respective resource can never change and may be cached indefinitely. The
max-age directive is only necessary as a fallback for browsers that do not support immutable caching. An immutable resource that the browser has cached will be available instantly on the next visit to the respective page and should generally have the same performance characteristics as a resource cached in a service worker's cache.
anchorStatic Prerendering and service workers
<body> that the application will render into once it starts up.
All of the above has lead to a result we are pretty happy with. While the design of our new page is for everyone to judge based on their own taste maybe, the performance numbers speak a clear language.
We were able to get there without giving up on the ease of maintenance of the content so that writing a new blog post is as easy as adding a Markdown file and opening a pull request.
And even though we spent an unreasonable amount of time and effort during the course of the project, there are many more things that we did not do or that I couldn't cover in this article but that should be considered best practices when optimizing for performance:
- CSS and optimizing it has huge potential to have significant positive impact on a site's performance (and sink lots of time 🎉); we used CSS Blocks which is great but worth a blog post of its own so I won't go into any details here.
- Images and their formats are a huge topic as well when it comes to performance and there are many low hanging fruits where simple changes can have a significant positive impact on a site's performance; things like inlining SVGs (or not if they are big or change often), using progressive JPGs or base64-encoded background images that get swapped out with the actual image after page load are to be named as well as using progressive images to avoid huge payloads on small viewports.
- Third-parties can have a significant negative impact on a site's performance and should generally be avoided (for example, you'll want to serve fonts from your own domain).
- Optimizing for performance is an ongoing project and not a one-off effort; there needs to be tooling in place to be aware of degrations and accidental mistakes, e.g. you could have Lighthouse integrated into your Github Pipeline (ideally for every route of the app), jobs that tell you how much weight a change adds to which bundles and which bundles it invalidates etc.
- Knowing is better than guessing and if you really care about your site's performance you need to measure using RUM.
By spending a significant (and maybe unreasonable) amount of time and energy we ended up with the highly optimized site you're looking at. The downside is we ended up with our own custom static site generator essentially that we now need to maintain ourselves (one of the reasons why we recommend using a fully integrated framework like Ember.js instead of compiling your own custom framework out of a bunch of micro libraries). However, it was definitely an interesting experiment and we hope you take some inspiration out of the patterns and mechanisms we describe in this post. If you are struggling with performance in your Ember.js or Glimmer.js or other apps, feel free to reach out and talk to our experts to see how we can help.