umbraco-performance
Keeping a package fast on the sites that install it: the published cache over the services layer, tree traversal, caching lookups and clearing them, data type config and converter cache levels, output
Files1
View on GitHubSKILL.mdplugins/umbraco-devkit/skills/umbraco-performance/SKILL.md
GitHubPerformance for Umbraco packages
A package's code runs on sites its author never sees: a 50-page brochure site and a 200,000-node multi-site install get the same DLL. Write for the big one. Every rule below is a pattern that's harmless on the demo site and costly in production.
This skill covers code a package ships: property value converters, content finders, Management API endpoints, notification handlers, indexers, background jobs, Delivery API output, and backoffice extensions. A site's own templates and its tuning (cache seed counts, turning output caching on, cache size limits) belong to the site owner. A package must stay fast with the site's defaults, not depend on the owner changing them.
Verify version-specific APIs against the CMS source and docs for the major the project
targets (umbraco-cms-source, umbraco-docs). The published content cache was rebuilt in
v15, so caching advice written for v13 and earlier is often wrong now. Search changes in
v19: Umbraco Search (Umbraco.Cms.Search) replaces the built-in Examine indexes and their
hooks. It's also an add-on for v17 and v18, so check which one the site uses, not just the
major.
🎯 Why: Design for Change
Slow code gets fixed by adding caches, and caches added late get bolted on in the wrong place, with no plan for clearing them. Deciding up front where data is read from, and what clears it, keeps a later speed fix a local change instead of a redesign.
Read published content from the cache, not the services layer
The services layer (IContentService, IMediaService, IRelationService and friends)
reads and writes the database. It's for changing data, not reading it on a hot path. Package
code that runs per front-end or Delivery API request (a converter, a content finder, a
rendered component) reads from the published cache instead:
// Hits the database on every call
var item = _contentService.GetById(key);
// Reads the published cache
var item = _publishedContentCache.GetById(key);
The published cache is lazy-loaded: an item is read from the database the first time it's requested, then served from memory. A cold item costs a database call, which matters when package code touches thousands of them (next section).
Source: Common pitfalls, Cache seeding.
Don't walk the whole tree
Descendants() / DescendantsOrSelf() visit every node below the starting point and load
each one. From the root, that's the whole site. A filter after it still loads everything
first:
// Loads every node in the site to find the products
var products = root
.DescendantsOrSelf(_navigationQueryService, _publishedStatusFilteringService)
.Where(x => x.ContentType.Alias == "product");
Since v15 this costs more than it used to: each IPublishedContent is rebuilt from the
cache when it's accessed, and items not yet cached come from the database. Package code that
walked a large tree comfortably on v13 has been reported many times slower on v15 to v17.
Test anything that traverses (sitemaps, feeds, link checkers, reports) against a large
content tree on the target major, with the site's default cache settings.
- Ask the navigation service tree questions.
IDocumentNavigationQueryService(and its media counterpart) answers parent/child/descendant/ancestor questions from an in-memory structure of keys, without loading content. Filter by key and type there, then load only the items you need from the cache. - Search the index, don't traverse. "Find every product with tag X" is a query, not a
tree walk. On v19, or v17/v18 with the Umbraco Search add-on, get an
ISearcherfromISearcherResolverand filter, sort and page in the query (skip/take). Without the add-on on v17/v18, query Examine. - Work that must cover the whole tree (a full sitemap, an export) runs in a background job or on demand, and caches its result. It doesn't run per request.
- A descendant walk is fine when the subtree is small by design (a settings folder). Say so in a comment, so a reviewer doesn't have to guess.
Source: Common pitfalls, Umbraco Search, CMS issues #18869 and #18892.
Navigation extensions: pass the services in
Children(), Parent(), Ancestors(), Descendants(), Siblings() and the rest of the
IPublishedContent navigation extensions (Umbraco.Extensions) are backed by the
navigation service. Each has an overload that takes the navigation service
(IDocumentNavigationQueryService, or IMediaNavigationQueryService for media) and
IPublishedStatusFilteringService. Package code calls those overloads, with both services
injected through the constructor:
internal sealed class ProductListingService(
IDocumentNavigationQueryService navigationQueryService,
IPublishedStatusFilteringService publishedStatusFilteringService)
{
public IEnumerable<IPublishedContent> GetProducts(IPublishedContent category)
=> category.Children(navigationQueryService, publishedStatusFilteringService)
.Where(x => x.ContentType.Alias == "product");
}
The short forms without those parameters (content.Children(), mostly in
FriendlyPublishedContentExtensions in Umbraco.Web.Common) look both services up from
StaticServiceProvider on every call. That's a service-locator lookup per call, repeated in
every loop, plus a hidden dependency a unit test can't replace. A package project that only
references Umbraco.Core doesn't get most of them at all. They're for templates, not
package code.
The IPublishedContent.Children property is obsolete in v17 and removed in v18. Use the
extension method.
Source: PublishedContentExtensions and FriendlyPublishedContentExtensions in the CMS
source (17.7.1, 18.2.1, v19/dev); IPublishedContent at 17.7.1.
Cache your own expensive lookups
Anything slow that package code repeats (a relation lookup, a call to an external API, a
computed aggregate) goes through Umbraco's AppCaches, not a static field or a private
MemoryCache:
AppCaches.RuntimeCache(IAppPolicyCache) for values shared across requests, with an expiry.AppCaches.RequestCachefor values reused within one request.
var relations = _appCaches.RuntimeCache.GetCacheItem(
$"MyPackage_ChildRelations_{key}",
() => _relationService.GetByChildId(id, "umbDocument"),
TimeSpan.FromMinutes(5));
- Prefix every cache key with your package name. The runtime cache is shared with core and every other package.
- Decide what clears it, at design time. An expiry alone means stale data for that
long. If the data changes when your entity is saved, clear the key there. An integration
test of that clearing needs
umbraco-dotnet-testing, or it passes whatever the code does. - Clear it on every server. On a load-balanced site the clear goes through a cache
refresher (
umbraco-extensibility's catalogue, row 4). - Refresh many items in one call.
DistributedCache.Refresh(refresherId, ids)orRefreshByPayload(refresherId, payloads)with the whole set, not one call per item in a loop. Outside a web request (a background job, an import), each call is written to the database for the other servers straight away. - Never hold a request-scoped object in a singleton or static field.
UmbracoHelper,IUmbracoContextand anything resolved per request belong to one request. Kept longer, they pin memory and can serve one user's data to another.
Source: Accessing the cache,
Common pitfalls,
BatchedDatabaseServerMessenger in the CMS source.
Read data type configuration from its cache
Converters, value editors and data sources often need their data type's configuration. Read
it from IDataTypeConfigurationCache (GetConfigurationAs<T>(dataTypeKey)), not
IDataTypeService. The service goes to the repository and clones the result on every call,
and a value editor runs once per property, per variant, per load.
Source: CMS issue #14936 and PR #15731.
Property value converters: set the cache level
GetPropertyCacheLevel decides how often a converter runs. Pick it deliberately:
| Level | Converted again when | Use for |
|---|---|---|
Element |
the owning page or block changes | the default; values that only depend on their own data |
Elements |
any content changes | values that reference other content (pickers) |
None |
every read | almost never; it's a large cost on every read |
PropertyValueConverterBase returns Snapshot, which is obsolete and treated as None
by the current published cache. A converter that inherits the base class without
overriding GetPropertyCacheLevel runs its conversion on every read. Always override it.
A converter that also implements IDeliveryApiPropertyValueConverter sets two more:
GetDeliveryApiPropertyCacheLevel and GetDeliveryApiPropertyCacheLevelForExpansion
(which defaults to the first). The same table applies, and Snapshot means None here
too, even where a docs example uses it. Expanded output depends on each request's expand
and fields, so a cached copy is served to requests that asked for a different shape.
Override GetDeliveryApiPropertyCacheLevelForExpansion to return None rather than
inheriting the default; the built-in converters do the same through Snapshot. Never put per-request data (the current time, the
current user) in a converter's output: any cache level serves it stale, and it stops a site
using Delivery API output caching.
Source: Property value converters,
Custom property editors support;
PublishedProperty.GetCacheValues in Umbraco.PublishedCache.HybridCache.
Output caching: evict when your data changes
From 17.4 (and every 18.x), a site can cache whole rendered pages and whole Delivery API responses. The package doesn't turn this on, but has to work when the site does:
- If package data feeds pages or API responses and it changes outside a content
publish (a product price, a review count from your own tables), evict what shows it:
IWebsiteOutputCacheManagerandIDeliveryApiOutputCacheManagerevict by content key or by tag. Otherwise visitors see the old value until the cache expires. - If the package renders output that differs per visitor (a member's name, a basket), mark that response as not cacheable. A cached copy of it is served to the next visitor.
Source: Website output caching, Delivery API output caching.
Keep hot paths light
Some code runs far more often than it looks like it does:
- Notification handlers on save/publish run inside the editor's save. A bulk publish runs them once per item. Do slow work (external calls, reindexing your own data) after the save: record it and let a background job pick it up. An in-memory queue loses work on a restart.
- Bulk operations (an import, a sync, a migration over many items) run in one scope, so
the work is one transaction and its notifications fire once, when the scope completes.
scope.Notifications.Suppress()stops them entirely, which is only safe for data your package owns. Suppressing around content or media service calls leaves the published cache and search index stale until they're rebuilt. - Indexing code runs once per item indexed, and a full rebuild runs it for every
document and media item. With Umbraco Search, that's
IContentIndexer.GetIndexFieldsAsyncandIPropertyValueHandler.GetIndexFields. With Examine on v17/v18, it'sTransformingIndexValuesand similar events. For the Delivery API index, on any of them, it'sIContentIndexHandler.GetFieldValues. A service lookup in there is one database call per item. Build fields from the content item you're handed. If you need related data, read it from the published cache or a cached lookup, not a service. - Startup code runs on every app start, on every site that installs the package. Do work lazily, when it's first needed. Run one-time setup through a migration, which records that it ran, not a check on every boot.
- Volatile data doesn't belong in content nodes. Counters, form entries, logs and
imported bulk data go in your own tables (
ef-core-data). Content saves are slow by design: versioning, notifications, cache updates, reindexing.
Source: Common pitfalls, ContentService notifications, Umbraco Search.
Management API endpoints: return what the list needs
- Page anything that can grow. A list endpoint takes
skip/takeand returns a total. Never return "all items" for the screen to page. - List-sized models. A list returns what its columns show; the full model is for the detail endpoint.
- No per-item URL building in lists. Resolving a content URL is slow next to loading the item. Core moved URLs out of its own document response into a separate endpoint for this reason. Return keys, and let the screen ask for URLs where it shows them.
Source: CMS issue #18910.
Backoffice extensions: don't over-fetch
- Use the paging the screen already has. Collection views have paging built in; wire
them to the endpoint's
skip/take. - Debounce search-as-you-type. Use
debouncefrom@umbraco-cms/backoffice/utils, so a search fires when typing pauses, not on every key press. - Fetch once, share it. Two elements on one screen that need the same data read it from a shared context or repository, not one request each.
- Don't refetch when an element reconnects. Block editors' sorting removes and re-adds
elements, so
connectedCallbackruns again on every drag. Server-rendered previews and other expensive fetches cache their result (for example by a hash of the value) and reuse it.
Source: Block Preview issue #149.
Reviewing a diff for performance
- A services-layer read (
IContentService,IMediaService,IDataTypeService, ...) on a per-request, per-property or per-item path. Descendants()/DescendantsOrSelf()from a root or other large node, on a per-request path or without a comment saying why the subtree is small.- A navigation extension (
Children(),Ancestors(), ...) called without the injected navigation and published-status services, or the obsolete.Childrenproperty. - A converter with no
GetPropertyCacheLeveloverride, or any cache level (including the Delivery API one) set toNoneorSnapshotwithout a stated reason. The Delivery API expansion level is the exception: flag it when it's anything butNoneorSnapshot. - A cache added with no rule for what clears it.
- A cache key without the package prefix.
- A request-scoped object stored in a singleton or static field.
- Package data shown on pages or in Delivery API responses, changed with no output cache eviction.
- A database or HTTP call inside a notification handler, a content indexer, a property value handler, an Examine event, or a loop over content.
- Bulk work outside a single scope, or
Suppress()around content or media service calls. - A list endpoint without paging, or with per-item URLs; a search box without a debounce;
an expensive fetch in
connectedCallbackwith no cache.