So you want to cache all the things?

So you want to cache all the things?

In Umbraco versions 14 and below, caching roughly works by loading everything into memory at boot time.

This caching strategy originates from the early versions of Umbraco. The web has undeniably changed a lot since then, and so has the scale of Umbraco sites. And as they’ve grown, so have the issues with loading everything into memory - particularly when it’s done during boot ⏳

Umbraco 15 changes this behaviour with a brand-new caching system. Instead of loading everything into memory, the cache is now seeded with a subset of all content (documents and media) at boot time, while the rest of the content is loaded into cache on demand.

That’s a quite meaningful caching strategy, if you ask me 🤓

Out of the box, Umbraco offers two cache seeding strategies:

  • Loading a configurable portion of the content trees in a breadth-first manner.
  • Loading all content of specifically configured content types.

The seeding strategies can be combined to fit your needs, and as with most things Umbraco, custom seeding strategies can also be implemented. Check the docs for more details.

But… cache all the things!

Now… If you still really want all content in cache, you can configure the breadth-first seeding to load an impossibly high number of content items. This will efficiently result in loading the entire content tree into cache.

However, since the cache seeding has to complete before the site can serve requests, this also brings you right back to square one with potentially exaggerated boot times.

Fortunately, there is another way. With the caching changes in V15, it’s now possible to perform custom cache warm-up on a background thread 🧵

I’ve added a code snippet below for loading all documents into cache in this manner. You can apply a similar solution to media. In a nutshell, here’s what happens in the code:

  1. A notification handler reacts to the “application started” notification, which is fired immediately after the site boot has completed.
  2. The notification handler queues a new task, which performs the actual cache warm-up.
  3. The warm-up performs a recursive traversal of the document tree and forcefully loads the published version of each document.
  4. If a document is unpublished, the recursion breaks, because everything in the structure below that document does not need to be in the cache.

All of the above should only happen, when the site is in the runtime level Run - not during install, upgrade, boot or any other runtime state.

using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Infrastructure.HostedServices;

namespace My.Site;

public class DocumentCacheWarmUpNotificationHandler : INotificationHandler<UmbracoApplicationStartedNotification>
{
    private readonly IBackgroundTaskQueue _backgroundTaskQueue;
    private readonly IRuntimeState _runtimeState;
    private readonly IDocumentNavigationQueryService _documentNavigationQueryService;
    private readonly IDocumentCacheService _documentCacheService;
    private readonly ILogger<DocumentCacheWarmUpNotificationHandler> _logger;

    public DocumentCacheWarmUpNotificationHandler(
        IBackgroundTaskQueue backgroundTaskQueue,
        IRuntimeState runtimeState,
        IDocumentNavigationQueryService documentNavigationQueryService,
        IDocumentCacheService documentCacheService,
        ILogger<DocumentCacheWarmUpNotificationHandler> logger)
    {
        _backgroundTaskQueue = backgroundTaskQueue;
        _runtimeState = runtimeState;
        _documentNavigationQueryService = documentNavigationQueryService;
        _documentCacheService = documentCacheService;
        _logger = logger;
    }

    public void Handle(UmbracoApplicationStartedNotification notification)
        => _backgroundTaskQueue.QueueBackgroundWorkItem(ExecuteAsync);

    private async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (_runtimeState.Level != RuntimeLevel.Run)
        {
            // do not warm up the cache unless the site is actually running
            return;
        }

        _logger.Log(LogLevel.Information, "Starting document cache warm-up...");

        if (stoppingToken.IsCancellationRequested)
        {
            _logger.Log(LogLevel.Information, "Received cancellation request, aborting.");
            return;
        }

        // get the keys of all root level documents
        if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable<Guid> rootKeys) is false)
        {
            _logger.LogWarning("Could not get root document keys, aborting.");
            return;
        }

        var count = 0;
        foreach (Guid rootKey in rootKeys)
        {
            if (stoppingToken.IsCancellationRequested)
            {
                _logger.Log(LogLevel.Information, "Received cancellation request, aborting.");
                break;
            }

            if (await LoadDocumentCache(rootKey) is false)
            {
                // the root document was not published, don't attempt to load descendants
                continue;
            }

            count++;

            // load descendant documents recursively
            count += await LoadDocumentCacheRecursively(rootKey, stoppingToken);
        }

        _logger.Log(LogLevel.Information, "Finished document cache warm-up - {count} documents were loaded into cache", count);
    }

    private async Task<int> LoadDocumentCacheRecursively(Guid parentKey, CancellationToken stoppingToken)
    {
        if (stoppingToken.IsCancellationRequested)
        {
            _logger.Log(LogLevel.Information, "Received cancellation request, aborting.");
            return 0;
        }

        // get the keys of all child documents
        if (_documentNavigationQueryService.TryGetChildrenKeys(parentKey, out IEnumerable<Guid> childrenKeys) is false)
        {
            _logger.LogWarning("Could not get child document keys of {parentKey}, aborting.", parentKey);
            return 0;
        }

        var count = 0;

        foreach (Guid childKey in childrenKeys)
        {
            if (stoppingToken.IsCancellationRequested)
            {
                _logger.Log(LogLevel.Information, "Received cancellation request, aborting.");
                return count;
            }

            if (await LoadDocumentCache(childKey) is false)
            {
                // this document was not published, break the recursion here
                continue;
            }

            count++;

            // load descendant documents recursively
            count += await LoadDocumentCacheRecursively(childKey, stoppingToken);
        }

        return count;
    }

    private async Task<bool> LoadDocumentCache(Guid key)
        => await _documentCacheService.GetByKeyAsync(key, false) is not null;
}

You’ll also need a composer to register the notification handler:

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Notifications;

namespace My.Site;

public class DocumentCacheLoaderComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder.AddNotificationHandler<UmbracoApplicationStartedNotification, DocumentCacheWarmUpNotificationHandler>();
}

Don’t cache all the things!

As a general rule, having everything in cache is a bad strategy. If you really need that to run your site, then maybe your content architecture needs a makeover to make things more efficient, or perhaps you need to optimize some of your content querying?

That being said, there is of course a case to be made for loading content into the cache, as long as you do it with consideration 🤔

I think the background thread warm-up is a pretty neat approach. If you require thousands of content items in the cache, it can take a while… by doing it in the background, the site remains responsive - albeit a little slower until the cache warm-up has completed.

You can also mix and match with the built-in cache seeding. For example, you could use seeding to perform eager-load of important content like site roots and landing pages, and subsequently load lesser important content on a background thread 👍

The time it takes to cache a single piece of content will vary greatly, depending on multiple factors including:

  • The complexity of the content type.
  • The server hardware.
  • The current server load.
  • The latency between the web server and database.

In my test setup it only takes 10-15 ms longer to render an un-cached document compared to a cached one. That’s a negligible time difference, and likely not worth the effort to do all this custom cache warm-up. But things might be different in your case, and definitively worth investigating I think 🔍

Happy caching 💜