So, users will receive SSR HTML from the CDN whenever possible. If not, the Web Server Lambda will get invoked by the API Gateway, and the SSR HTML will be returned, be it directly from the database, or by generating it on the spot (as seen, this happens when the SSR HTML does not exist, not even an expired one).

The only difference is that, as mentioned, we are sending much longer MAX_AGE value in the response headers, e.g. a whole month ( Cache-Control: public, max-age=2592000 ). Note that if the request reaches the Web Server Lambda and we determine that we have an expired SSR HTML cache in the database, we still respond with the short Cache-Control: public, max-age=10 response header. This didn’t change.

With this approach, we have much less Lambda function invocations, because most of the time, users will hit the CDN, which means users won’t experience cold start latencies that much, and we can also worry a bit less about Lambda functions generating a lot of costs. Nice!

But now we have to think about cache invalidation. How do we tell the CloudFront CDN to clear the SSR HTML it possesses, so a new one can be fetched from the Web Server Lambda ? This happens very often, for example, when an admin makes changes to an existing page via the Page Builder and publishes it.

When you think about it, it should be pretty straight-forward, right? Every time an admin user makes changes to an existing page and publishes it, we can just programmatically invalidate the cache for the page’s URL and that’s it, right?

Well, actually, that’s only a part of the complete solution. We still have some other key events on which the CDN cache should be invalidated.

For example, our Page Builder app supports many different page elements that you can drag onto your page, one of which is the element that lets you embed forms from our Form Builder app. So, you add a form to your page, publish the page, and it’s all good. But what if someone makes changes on the actual form, for example, adds additional fields to it? If that happens, site users must be able to see these changes (the SSR HTML must contain them). So, the “just invalidate on page publish” idea will not be enough here.

But there’s more! Let’s say an admin user makes changes to the site’s main menu. Since the menu can be seen on every page basically, does that mean we should invalidate the cache for all pages that contain it? Well, unfortunately, yes, there is no other way around it. But before we do that, is there anything we should know about cache invalidation pricing?

The total number of pages that contain the menu could vary from 10 to 20 pages for smaller sites, but for larger ones, we could easily have hundreds or even thousands of pages! So, this could force us to create a lot of cache invalidation requests to the CDN, and if you check out the CloudFront’s pricing page, we can see that these aren’t cheap:

No additional charge for the first 1,000 paths requested for invalidation each month. Thereafter, $0.005 per path requested for invalidation.

As we can see, if we were to implement the basic “just invalidate all pages that contain the menu” logic, we would probably break out of the free tier very quickly, and basically start paying $5 for every additional 1,000 invalidations we make. Not good.

Because of this, we started to think about alternative ideas and we came up with the following.

If a change in the menu happens, let’s not invalidate the cache for all pages that contain it. Instead, let’s check if the page needs to be invalidated only when actually visited. So, every time a user visits a page, we’ll make a simple HTTP request (triggered asynchronously so it doesn’t affect the page performance), that will invoke a Lambda function which checks if the CDN cache needs to be invalidated, by checking if the SSR HTML stored in the database has expired, be it because enough time has passed since it was generated, or it was simply marked as expired in one of the key events (for example a menu was updated, or page published). If so, it will just fetch the new SSR HTML and send an invalidation request to the CDN.

This means a couple of things.

First, for every page visit, we’ll have a Lambda function invocation. But one of the reasons we wanted to try this longer max-age (TTL) approach is to actually avoid that. Unfortunately, it cannot be avoided. The good news is that you can reduce the number of invocations by simply triggering this check less frequently. Trigger it once in one, five, or even ten minutes — whatever works best for your case.

Second, invalidating CDN cache takes time, so the fresh SSR HTML will arrive anywhere from 5 seconds to 5 minutes, or even later, depending on the current status of the CDN. In most cases, it will be pretty fast, 5–10 seconds on average is what we’ve experienced.

Trigger invalidation selectively with custom HTML tags

As seen, the “menu-changed” event we’ve seen is an important event that must trigger the cache invalidation for not just one page. But, let’s say we’re updating a secondary menu that’s located only on a small number of pages. Once updated, we definitely don’t want to mark all of our site’s pages as expired, right? So naturally, the question that comes up is: is there a way we can be more efficient and only invalidate cache for pages that actually contain the updated menu?

Because of this issue, we decided to introduce HTML tagging. In other words, we utilize our own custom ssr-cache HTML tag to purposely tag different HTML sections / pieces of UI.

For example, if you are using the Menu React component (provided by our Page Builder app) to render menus on your pages, besides the actual menu, the component will also include the following HTML when it gets rendered:

<ssr-cache data-class="pb-menu" data-id="small-menu" />

A page can have multiple different tags like this (you can also introduce your own), and all of them will be stored in the database when doing SSR HTML generation. Let’s take a look at an updated database entry:

{

"_id": ObjectId("5e2eb625e2e7c80007834cdf"),

"path": "/",

"cacheTags": [

{

"class": "pb-menu",

"id": secondary-menu"

},

{

"class": "pb-menu",

"id": "main-menu"

},

{

"class": "pb-pages-list"

}

],

"lastRefresh": {

"startedOn": ISODate("2020-01-27T10:06:29.982Z"),

"endedOn": ISODate("2020-01-27T10:06:36.607Z"),

"duration": 6625

},

"content": "<!doctype html><html lang=\"en\">...</html>",

"expiresOn": ISODate("2020-02-26T10:06:36.607Z"),

"refreshedOn": ISODate("2020-01-27T10:06:36.607Z")

}

All of the ssr-cache HTML tags that are contained in the received SSR HTML are extracted and saved in the cacheTags array. This enables us to query the data more easily later down the road.

As we can see, the cacheTags array contains three objects, where the first one is { “class”: “pb-menu”, “id”: “small-menu” } . This simply means that the SSR HTML contains a Page Builder menu ( pb-menu ), that has the ID secondary-menu (the ID here actually is represented with menu’s unique slug , which is set via the admin UI).

There are more tags like this, for example pb-pages-list . This tag simply signifies that the SSR HTML contains Page Builder’s “list of pages” page element. It exists because if you’re having a list of pages on your page, and a new page is published (or an existing one is modified), the SSR HTML can be considered as expired because the list of pages that was once on the page might have been affected by the newly published page.

So now that we understand the purpose of these tags, how do we utilize them? Pretty simple actually. To make things easier for developers, we’ve actually created a small SsrCacheClient client, with which you can trigger invalidations either by specific URL path or by passed tags, using invalidateSsrCacheByPath and invalidateSsrCacheByTags methods, respectively. You would use these when the SSR HTML needs to be marked as expired and cache invalidated, in key events you define.

For example, when a menu has changed, we execute the following (full code):

await ssrApiClient.invalidateSsrCacheByTags({

tags: [{ class: "pb-menu", id: this.slug }]

});

And when a new page was published (or existing one deleted), all pages that contain the pb-pages-list page element need to be invalidated (full code):

await ssrApiClient.invalidateSsrCacheByTags({

tags: [{ class: "pb-pages-list" }]

});

Base Webiny apps, like Page Builder or Form Builder, are already utilizing both ssr-cache tags in the React components and the SsrCacheClient client on the backend, so you don’t have to worry much about this. And if you’re doing custom development, in the end, it basically comes down to recognizing the events that must trigger the SSR HTML invalidation, placing the ssr-cache tags in your components and using the SsrCacheClient client appropriately.

Results

The solution with long-lived TTLs is good, but again, it’s not the ultimate one.

The most significant factor that might tell you if this is a good approach for you, is the amount of changes that are happening on your site. If changes (specific events that must trigger SSR HTML invalidation) are happening very often, for example, every few seconds or minutes, I would definitely not suggest this approach because cache invalidations would happen practically all the time, and that somehow defeats the purpose. In that case, you might be better off with the Solution 1 we’ve seen earlier. But analysing and testing your app is the key.

Also, if a certain page is not visited for a long time, and its SSR HTML was marked as expired in the meantime, the user that first visits it will still see the old page. Because if you’ll remember, in cases where a key event triggered the SSR HTML invalidation for several pages (like the “menu changed” event), the actual cache invalidation is triggered by users actually visiting the page, instead of us sending a massive amount of cache invalidation requests to the CloudFront and spending money while doing it.

But overall, considering the stellar speed benefits and asynchronous cache invalidation that this solution offers, we think this is a good approach to utilize.

We’ve actually made this the default caching behavior for every new Webiny project, but you can basically switch to the Solution 1 by easily removing a couple of plugins. Be sure to check our docs if you want to find out more information on that.

Conclusion

Did you make it to the end? Woah, I admire you!

Jokes aside 🙂, I hope I’ve managed to convey some of our experiences to you, and that you’ve received some value out of this article.

We’ve learned a lot of different things today. Starting from fundamental concepts of Single Page Applications, their lack of SEO support, and different approaches of rendering on the web, to implementing two of these approaches (that worked best for our Page Builder app) in a serverless environment — prerendering on-demand and SSR with (re)hydration. And although the mentioned lack of SEO support has been resolved with both, unfortunately, by default, these approaches do not offer an acceptable performance when it comes to the page load time. Sure, if a loading screen is not an issue for your particular app, then prerendering on-demand might work for you. But if not, SSR with re(hydration) is probably your best choice.

We’ve also seen that these approaches can be implemented relatively easily in a serverless environment, using just a few of the AWS serverless services — S3, Lambda, API Gateway, and CloudFront. Although we don’t have to manage any of the physical infrastructure for all of this to work, we still have to take into consideration the amount of RAM that we will allocate to the Lambda functions. For basic file serving needs, a minimum of 128MB RAM will suffice, but for doing prerendering on-demand or SSR, which are resource-intensive tasks, we must allocate more. Allocate carefully and test appropriately, since this can impact your monthly costs. Make sure to check out the pricing pages for each service, and try to do estimates based on your monthly traffic.

And finally, to tackle slow SSR generation and function cold starts, we utilized CDN caching, which can make a significant difference in terms of performance and cost. Depending on what works best for our case, we can do caching with either short or long max-age / TTLs. If we choose to go with the latter, manual cache invalidation will be required. And if there will be a lot of those because the content is too dynamic, you might want to rethink your strategy and see if using shorter max-age (TTL) values is a better solution.

As always, there is no silver bullet for any problem and the topic we covered today is certainly a good example of that. Trying out different things is the key, it will help you in finding what works best for your particular case.

Oh, and by the way, the good news is that, if you don’t want to torture yourself and want to avoid implementing everything from scratch, you can give Webiny a try! You can even choose between the two different SSR HTML caching approaches we’ve shown, by applying a specific set of plugins. We like to keep it flexible. 🙂

Feel free to check it out if you’re interested! And do catch us on Gitter if you have any questions, we would be glad to answer them!