State-of-the-art LCP performance for Ember.JS

Background

Google have recently announced it will be rolling out an update to search ranking based on “Core Web Vitals” starting May 2021.

It will use 3 signals for this new ranking bump:

LCP: Largest Contentful Paint

This metric is used to signal fast loading on a site, it is collected using the largest-contentful-paint PerformanceEntry. The browser basically watches for the largest element on screen (in a particular allow list), once it is rendered it has the time.

FID: First Input Delay

This metric is used to measure how responsive a page is during loading. It watches for the delay between the time a user clicks / scrolls or types and the time the browser reacts.

If you are stuck in a 1 second JavaScript loop and never do any requestAnimationFrame() calls you risk this number going high.

CLS: Cumulative Layout Shift

This metric searches for cases where layout “shifts” as a result without the user acting. Like, for example, an image in a topic where dimensions are not specified.

These 3 metrics LCP/FID/CLS are now going to become signals for Google. Meaning that if all things are equal content/PageRank wise LCP/FID/CLS will become tie-breakers leading to the poorer performing (according to Google) site ranking lower.

For those interested in the actual implementation see:

Penalizing PWAs

Tom Dale ominously blogged about this particular topic back in 2015 when design started on FastBoot.

Instead, client-side rendering and server-side rendering have always had performance tradeoffs. Ember’s FastBoot is about trying to bend the curve of those tradeoffs, giving you the best of both worlds.

Fast forward to 2021 and much has changed in the world.

Google have decided to double down on the push towards AMP and decided to give the world a minor concession on AMP by allowing web vitals to act as signal.

Some unfortunate collateral of this change are:

Interior links in PWAs count for nothing

An enormous appeal for PWAs is that you accept a slightly slower initial page view and in return get spectacular low bandwidth performance and extremely fast page transitions. A JSON payload is usually a lot smaller than a fancy HTML page with fancy header and footer.

The penalty means that Ember PWAs look to Google significantly slower than what they really are.

This also biases Ember apps for inferior CLS scores, simply cause apps are significantly longer lived.

Web Vitals scores are only collected from Google Chrome users

This heavily biases Android, which is woefully slower than iPhones. A top-end Android today is significantly slower browsing the web than a 2017 iPhone.


(source)

Further more, many Android phones sold today are significantly slower than the top end Android phones.

iPhones are absent from Web Vitals on mobile.

Discourse

We started using Ember.JS at Discourse back at 2013, we have kept up with Ember releases. Recently @eviltrout completed a multi month project that upgraded us to Ember CLI.

Throughout the years we have been delighted to use a framework that offers the level of flexibility and maintainability that Ember does.

We have also accrued technical debt over the years including jQuery and 2 additional rendering engines that were introduced as performance hacks to work around issues older versions of Ember had.

Now that we are upgraded to Ember CLI we are poised to upgrade to Octane and Glimmer 2, both of which have very exciting performance profiles. We hope to shed some of our legacy (like alternative rendering engines) as we run through the next upgrades.

At Discourse we have been extremely good citizens when it comes to web performance. CDN support was always baked in, we introduced Brotli support way ahead of other platforms, we started using HTTP/2 back when it was SPDY, we have audited performance and tweaked performance countless times.

That said, Google have decided to push this particular grim picture to many of our customers:

Only 20% percent of the phone users of a Discourse site get a “green” mark according to Google. It is quite frustrating.

It is extremely hard to communicate the extreme amount of nuance to customers who simply see red and ask us “why?”.

A long and somewhat circular discussion happened at: Google May 4th Core Update impact on Discourse forums - community - Discourse Meta.

Upwards and onwards

It is quite clear to me that, “Google will be Google”. It is very hard to move a giant ship. The picture of “you suck compared to a static HTML site and we will punish you”, is just hard to shake.

I would though like not to focus any discussion here on

“Not fair, Android sucks”
“Not fair, Google is not measuring right”
“Not fair, Google are comparing Elephants to Bananas”

Instead, I would like to focus the discussion on the future first render performance at Discourse and other large Ember applications.

Despite how unfair all of this may seem, it is hard to argue that it is delightful to click on a link in Google search results on a 2 year old phone and have it render sub 2 seconds.

Discourse is now extremely mature. We are done taking bets on hacked raw HBS to workaround issues. Instead we would like to focus on existing and upcoming tooling and technology the Ember ecosystem has on offer.

If you were going to work on improving the LCP metric, which Ember and Ember ecosystem technologies would you double down on?

Some directions that come to mind are:

  • FastBoot, this feels like a gigantic effort for Discourse. We reach out to the DOM quite a lot, operating without it would be an enormous effort. Is there a middle-ground FastBoot + DOM out there?

  • Embroider, this seems like a great area to explore given tree-shaking and code-splitting

  • Upgrade to latest Ember and start moving more and more stuff to lightweight Glimmer components where possible. Octane upgrades. Removal of legacy like jQuery and custom rendering engines

  • Ember Engines, though not particularly recommended at the moment it is important to mention that the entire LCP issue is isolated to topic and home pages, the majority of routes Discourse have are not a concern when it comes to LCP as the fast majority of entry traffic just hits a topic in a search.

  • Web workers which can introduce parallelization, even if only uses for a subset of functionality. An interesting thing about Android is that there are a lot of cores to play with.

What directions would you recommend Discourse and other large Ember code-bases take? Are there any other tools and ideas we should explore?

9 Likes

Excited to hear about the progress you have all made!

There are actually a bunch of these that end up supporting each other, I think, but your first priority should be Octane:

  1. The performance of Glimmer components and autotracking is indeed a substantial real-world win over Classic components and the Classic reactivity system—despite performance not having been a focus in 3.x! We’ve completed the migration of some major chunks of the LinkedIn.com app, and while I can’t share numbers publicly yet, they’re a really solid improvement. (Happy to discuss more in a DM!) You should get real wins on both initial and subsequent rendering from getting Discourse there.

  2. For your purposes, you are absolutely going to want SSR and Fastboot, as those are likely to be the biggest impact to your metrics here… but the path to get there is through Octane. In particular: the Classic model required you to do the DOM munging you need to do in components, and retrofitting those components to distinguish between when you do or don’t have the DOM available is painful. The Classic parts of the LinkedIn.com codebase are littered with IS_BROWSER checks for that reason, and it’s kind of terrible.

    However, actually adopting Octane (not just a minimal “migration” but truly moving your code into the new mental model) includes adopting modifiers to manage those DOM interactions. The mental model for Octane is roughly:

    • components with @tracked state, actions to update that state, and purely-functional getters and helpers and components deriving from that tracked state—that is, really embracing one-way data flow/DDAU
    • modifiers, including custom modifiers, for bridging into the imperative and event-driven DOM APIs: these give you a controlled way of managing those “effects”

    But the secret sauce there in terms of FastBoot is modifiers: they don’t run in SSR, because Ember knows that modifiers need a real DOM to run against. So your initial DOM can be stable and ready for rehydration, and then modifiers can do the work to do the additional imperative and event-driven work you need.

  • As you suggest, getting rid of stuff like jQuery should also help substantially—not least just in decreasing your bundle size across the wire. Being up to date in general and specifically moving to all Octane idioms should also help with that, as we should be able to start “deprecation shaking” early in the 4.x era to simply not bundle Classic features that you’re not using.

Engines and lazily-loaded addons are the primary primitive we currently have for “chunking”, and they’re definitely useful here just for minimizing that initial bundle size and parse size. If you have a long roadmap to get there, though, moving more directly to Embroider may be more worth your time, as it makes industry-standard code-splitting primitives available and will give you non-trivial (if incremental) wins via tree-shaking. I would approach that work in parallel with upgrading Ember versions and making your way to Octane. Once you’ve done those pieces, I’d reevaluate!

3 Likes

I really think FastBoot will give you the biggest wins.

You don’t have to make every route work in fastboot. You can choose which URLs to send to the fastboot server and which to continue serving with the empty index.html. I bet most of your Google stats come from a small number of routes, and if you made only those work in fastboot you’d get most of the benefit.

From my own experience, getting a big complicated app to render in fastboot is often not that bad. Going all the way back to the introduction of Ember.Component, the happy path was designed to push people toward being SSR-safe. For example, the component lifecycle hooks that have access to this.$ and this.element naturally don’t run in fastboot. The most common thing that breaks is direct access to window and document.

The biggest part of the effort IMO is standing up robust production infrastructure – monitoring, deployment, scaling, etc.

3 Likes

Another benefit for Fastboot in your case is you may be able to put an extra layer of cache in front, so that for most requests the markup can be sent straightaway without even running the ember app in node (or perhaps even pushed to CDNs).

In theory, you are free to cache the output of any of the public pages, and any outdated content (like count, etc) will just be updated on rehydration. Perhaps you can even send those cached markup to logged-in users too and have rehydration take care of the difference. If you do that, you may need to tweak the structure and content of the markup to optimize for minimal churn and UX, but it seems doable.

Fastboot/rehydration isn’t really designed with that use case in mind (having to reconcile stale content during rehydration), so you may run into minor/fixable issues, but the general idea of the rehydration algorithm is to try to be quite resilient and preserve as much DOM as possible, so I think it shouldn’t be too much of a stretch to make it do that.

3 Likes

Thank you all for the great advice. Sounds like a very solid plan.

Start by upgrading to latest → moving more component to glimmer → dropping heavy dependencies

Next up experiment with FastBoot on our “Topic page” route (the route for this actual page :slight_smile: )

Given we are a Ruby app, has anyone tried running FastBoot in MiniRacer?

There are some pretty concrete advantages in using V8 in process (for us), we already have all the hosting infra tuned for this use case.

A very nice thing in MiniRacer is v8 snapshot support. We could eval all our scripts upfront and then snapshot. Load snapshot → generate page during the request → throw away context. Means we would not need to shuffle between 2 processes to server a cold page.

Has anyone experimented with jsdom in FastBoot? One thing that worries me is the giant plugin ecosystem we have at Discourse. It is easy enough to get our stuff into a reasonable shape but moving the entire ecosystem may take a year.

3 Likes

BTW, that is great news. If I follow discourse/DEVELOPER-ADVANCED.md at master · discourse/discourse · GitHub will I end up with a running ember-cli to poke at? I would be happy to test Discourse under Embroider.

1 Like

One disadvantage is that FastBoot is designed to have some kind of persistent state that can be shared between requests to save script parsing time, etc – think eager loading in Rails. If you boot a new context each time it will be at least somewhat wasteful from that perspective.

Plus – the off-the-shelf FastBoot code definitely requires the node environment and standard library. If you really want to, you may be able to skip fastboot and reimplement the functionality directly with the underlying Ember primitive (the visit API), but I’m not sure it’s really worth it given the other drawbacks.

1 Like

As far as plugins – you may not need to run most of them on the server. The general idea of fastboot is to do as little as possible and generate a “good enough” page and send it down the client, and have it “progressively” enhance the result during rehydration. I suspect a lot of plugins would actually fit in that description, but I’m not sure if the metadata you have allows you to make that kind of distinction.

2 Likes

One idea is to implementing SSR as an extra standalone caching layer. In development, you don’t really have to worry about setting it up (unless you are specifically debugging SSR). In production, the node app will be responsible for doing SSR and caching the result for subsequent requests, and forward anything else to the Rails app. That way, the Rails app wouldn’t have to worry about any of the SSR stuff.

1 Like

That would be amazing, I am sure @eviltrout would be delighted as well and happy to provide you with assistance. Our developer advanced document certainly needs an update.

To fire up ember cli you would run:

bin/unicorn 
bin/ember-cli

We are currently on 3.12.2 hopefully Embroider can work with it. If not we will ping you next time we upgrade.

I follow, I was thinking we could use snapshots which kind of allow you to have a cake and eat it at the same time:

snapshot = MiniRacer::Snapshot.new('function hello() { return "world!"; }')
context = MiniRacer::Context.new(snapshot: snapshot)
context.eval("hello()")

Loading snapshots is super fast and it would mean that we can afford context pollution with zero risks.

Standard node library though is a bit of a challenge.

I really like the idea of starting “as small” as possible with FastBoot, we could bypass all the post decoration stuff that happens in runtime which is the main way plugins fiddle with the rendering.

1 Like