Recently, I’ve been working on a brand-new website for a client. This website is your typical marketing and informational website complete with high resolution images, colorful backgrounds and long swaths of information. As such, it wasn’t totally clear at first how performance would be impacted; a lot of the time, media-rich websites tend to suffer a bit when it comes to page speed. However, after a bunch of trial and error, the team and I were able to come up several solutions to make this site as performant as possible.

As you can see, we’re getting mind-blowing lighthouse scores; the above scores are what we achieved for both desktop and mobile. Personally, I rarely (if ever) have seen perfect scores across the board, which is a true testament to the effort we put into making this website the most performant it could be.
You may notice that there’s no SEO score; our site isn’t fully launched yet and so we’re currently not indexing this through any search engines, once we go live I’ll be sure to update this post!
We employed a few different methods to achieve these scores, which I’ll outline below. Some are more pertinent to front-end work which I won’t fully cover here, but I’ll touch on what I can. This post will mostly go over what we can do in .NET Core and Sitecore in order to pump out great scores.
Our Methods
To start, let’s look at a high level of what we did:
- Implemented custom “srcset” responsive image tag helper & lazy loading into all images
- Added appropriate cache control headers for all static asset files
- Made use of Sitecore’s built in rendering caching
- Implemented memory caching into the .NET Core rendering host application
- Minified all production CSS and Javascript
- Explicitly set heights and widths to all images, both static and those coming from Sitecore/Experience Edge
- Various other front-end-related caching approaches
As I mentioned above, I’ll mostly be covering the back-end/Sitecore related caching methodologies. If there’s interest, I can add some more insights on other caching topics in a part two blog post.
Responsive Image Tag Helper & Lazy Loading
The first approach we took happened close to the beginning of our development cycle. We opted early on to use responsive imagery tags to render images on the site respective to device size. This was a relatively straightforward approach as we could use the built-in Image Field tag helper and simply expand off of that.
I started by retrieving the base Image Field tag helper code and created a new ImageSrcSetTagHelper class. Using the base code, I expanded the available attributes by adding a “width” attribute which takes in an array of integers representing the different possible image sizes. I also added in a a “loading” attribute to handle lazy loading, though you could simply add in the vanilla attribute here if you wish. These, in addition to some regular/common attributes made up the base for the srcset helper tag.
An example of this tag being used in a component is below:
<sc-imgsrcset asp-for="Image" asp-widths="@(new int?[] { 400, 800, 1200, 1600, 2000, 2400, 2800, 3200 })" asp-loading="lazy" sizes="(min-width: 48rem) 800px, 100vw" style="--image-height: 2400; --image-width: 3200;"></sc-imgsrcset>
However, I still needed to wire up these new attributes in code. Expanding off of the original Image Field tag helper, I first added two small conditionals that simply checked to see if the loading attribute was present in the tag. If it was, the value provided would be added to the final markup tag. You’ll need to customize both the Process function and the GenerateImage method with these changes.
// In the Process function...
if (!string.IsNullOrWhiteSpace(Loading))
{
output.Attributes.Add("loading", Loading);
}
// In the GenerateImage method
if (!string.IsNullOrWhiteSpace(Loading))
{
image2.MergeAttribute("loading", Loading);
}
I then followed a similar approach for the widths, however this one was a bit more complicated. In order to properly make use of responsive images, I needed to take the array if integers passed through the tag and appropriately set the srcset attribute with images that utilize these.
In the same two areas we added our loading attribute conditionals, I added the logic to compile all of the width-related attribute values. To put it simply, I just checked to see if there was an array present and if so, looped through each value and took any width values that were smaller than the image’s natural size and passed these through to a helper method I created called GetImageSourceSet.
Below is some example code on how to achieve this.
// We take our original list of widths up to the actual size of the image.
if (Widths != null && Widths.Any() && imageField.Value.Width != null)
{
var formattedWidths = new List<int?>();
foreach (var width in Widths)
{
if (width < imageField.Value.Width)
{
formattedWidths.Add(width);
}
else
{
formattedWidths.Add(imageField.Value.Width);
break;
}
}
// Grab image srcset value using formatted widths.
output.Attributes.Add("srcset", imageField.GetImageSourceSet(formattedWidths.ToArray()));
}
The reason that I only cared about widths that were smaller was simply because I didn’t want the images to stretch out if they were naturally small. Since our images (and by virtue image sizes) can be changed at any time in Sitecore, our front-end developers came up with a series of widths that could plausibly work in a given component regardless of the currently-set image’s dimensions.
In the example above, we used 400, 800, 1200, 1600, 2000, 2400, 2800 and 3200 as our possible widths. If the displayed image was 1800px though, we’d only be concerned with 400, 800, 1200 and 1600.
As I mentioned above, I created a helper method called GetImageSourceSet. All this really does is take the image field’s value and retrieves a media link for each width with that width attached. For example, if I’m dealing with the width 200, I pass that and the image field into the helper method, which returns a media link for the image linked in the image field plus the width attached. The code for the helper method is below:
public static string GetImageSourceSet(this ImageField imageField, int?[] widths)
{
if (imageField?.Value != null && !string.IsNullOrEmpty(imageField?.Value?.Src))
{
if (widths.Any())
{
var imageSourceSet = "";
foreach (var width in widths)
{
var imageUrl = imageField.GetMediaLink(new {w = width ?? 0});
imageSourceSet += $"{imageUrl} {width}w,";
}
return imageSourceSet;
}
// If we haven't passed through any widths, simply return the image as is.
return imageField.GetMediaLink(new { }) ?? "";
}
return "";
}
Finally, the last step is simply to set a name for the tag for use in the view files. I simply went with “sc-imgsrcset”. In the Sitecore .NET Core SDK, Sitecore field tag helpers are prefixed with “sc-“, so I kept that naming convention. I simply modified the existing tag name from the base code.
[HtmlTargetElement("sc-imgsrcset", Attributes = "asp-for, asp-widths, asp-loading", TagStructure = TagStructure.NormalOrSelfClosing)]
And there you have it! Using the tag I posted at the start of this section, the final output looks like this:
<img alt="Some example alt text" class="" height="1100" loading="lazy" sizes="(max-width: 480px) 400px, (max-width: 780px) 800px, 760px" srcset="https://edge.sitecorecloud.io/project/media/Images/sample.jpg?w=400 400w,https://edge.sitecorecloud.io/project/media/Images/sample.jpg?w=800 800w,https://edge.sitecorecloud.io/project/media/Images/sample.jpg?w=1200 1200w,https://edge.sitecorecloud.io/project/media/Images/sample.jpg?w=1600 1600w," style="--image-height: 1800; --image-width: 3200;" width="1651">
Cache-Control Header/Static File Caching
I’m grouping two approaches we took into one section here as they are all handled within the .NET Core application’s Startup.cs class (depending on your version of .NET Core, these may instead live in Program.cs) and are interrelated.
These were pretty simple to implement, but made such a difference in the performance of the website. We added a cache-control response header to indicate the max age of some static assets we were using on the site. In our case, these ended up being CSS, JavaScript and image files.
The approach simply involves implementing the .NET Core UseStaticFiles extension in the Startup.cs class. When sending the response back to the browser, I checked for the specified file types and added the cache-control header to any of them that matched. The code for this is below.
app.UseStaticFiles(new StaticFileOptions()
{
OnPrepareResponse =
r =>
{
string path = r.File.PhysicalPath;
if (path.EndsWith(".css") || path.EndsWith(".js") || path.EndsWith(".gif") || path.EndsWith(".jpg") || path.EndsWith(".png") || path.EndsWith(".svg") || path.EndsWith(".webp"))
{
TimeSpan maxAge = new TimeSpan(7, 0, 0, 0);
r.Context.Response.Headers.Append("Cache-Control", "max-age=" + maxAge.TotalSeconds.ToString("0"));
}
}
});
You can place this directly in the Startup.cs class; I put this directly under where other headers were being set. You may have also noticed that I set the cache duration to 7 days, however feel free to adjust this to fit your needs. I also made sure to append any references to these files (particularly the CSS and JavaScript ones) with the .NET Core attribute “asp-append-version”, which appends a query string to the asset’s URL based on the current build. This means that if I have changes to this file and perform another build, the URL will update and thus clear that file’s cache. An example is below:
<script type="module" src="~/assets/js/index-generated.js" asp-append-version="true"></script>
Sitecore Rendering Caching
Another easy win here. As this was my first headless/XM Cloud project, I wasn’t totally certain if the traditional methods of rendering caching would work. However, they totally do! As this is headless and not MVC, we had to use JSS rendering items instead of View rendering items. This thankfully doesn’t change much when it comes to caching opportunities. I was still able to set the rendering as cacheable, and select my caching methods. Super easy, not much more to say here!

.NET Core Memory Caching
.NET Core has some built-in caching known as Memory Caching. As the name suggests, memory caching is simply a cache that’s stored in the memory of your rendering hosts’ environment. In our case this was an Azure app service. The documentation goes into a lot more detail, however to keep this blog post simple, I’ll just go over the high level points.
In Startup.cs, we added the AddMemoryCache service extension. This allowed us to make use of the IMemoryCache interface. There’s nothing Sitecore-specific about this, however you can make use of this with Sitecore.
services.AddMemoryCache();
In our case we used this to help with some GraphQL and dictionary caching. Simply put, you’re caching information in memory, and retrieving it/refreshing it when you need. A lot of what we used here was leveraged from another project, which made it really straightforward to implement. For example, take a look at the following constructor:
private readonly IMemoryCache _cache;
public SomeExampleService(IServiceProvider serviceProvider, IConfiguration configuration)
{
_cache = serviceProvider.GetRequiredService<IMemoryCache>();
}
Using _cache then allows us to set and retrieve memory cached values. For example we can set our cache’s duration, key and value as such:
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10);
var key = "SomeKey";
var value = new Dictionary<string, string>
{
{ "DictionaryKey1", "DictionaryValue1" }
};
_cache.Set(key, value, cacheOptions);
Conversely, you can retrieve the cached values like so:
_cache.TryGetValue(key , out Dictionary<string, string> value)
Simple stuff right?
As always if you have any questions, feel free to leave them below. Until next time, happy caching!

Leave a comment