“Abstract illustration of multiple requests converging into a shared cache, with connected content and data storage representing Optimizely caching and invalidation.

The Under-Documented Optimizely Caching API I Somehow Missed

By Dom Reilly · Posted 31 Aug 2026 · 7 min read

I was recently implementing some caching of data using the ISynchronizedObjectInstanceCache following a conversation with Optimizely support, when I came across a method I genuinely didn't know existed:

ReadThrough.


I've worked with Optimizely for years, used ISynchronizedObjectInstanceCache plenty of times and written more variations of Get, check for null, then Insert than I care to remember; yet, I've never stumbled across this method.


Me being me, I ended up doing a bit of a deep dive into the method, scouring the internet and finding limited information.


At first, I thought ReadThrough was just a nicer way of wrapping the usual cache-aside pattern i.e.

var cachedValue = cache.Get(cacheKey);

if (cachedValue == null)
{
cachedValue = GetValue();
cache.Insert(cacheKey, cachedValue, evictionPolicy);
}

return cachedValue;

Which, to be fair, would still be useful. It's less code to maintain and removes a pattern I've probably manually implemented hundreds of times by now.


But then I spotted the ReadStrategy argument.


The implementation I was looking at was using:

ReadStrategy.Wait

And this is where ReadThrough becomes a little more interesting.


Let's say the cache has expired and two requests hit the application at roughly the same time. With my usual Get and Insert implementation, request one checks the cache and finds nothing. Before it has finished getting the value and putting it back into the cache, request two does exactly the same thing.


Now, both requests have missed the cache and both go off to get the same data - sure, there's locking to prevent this, even double locking, but for this example, we'll ignore that.


Anyway, imagine that we have a busy application, or make GetValue() something expensive like a database query or an API request, and suddenly a cache miss can cause a burst of identical calls - not ideal!


Thankfully, ReadStrategy.Wait handles that scenario for us!


When the first request misses the cache, ReadThrough marks that particular item as currently being loaded and executes the delegate. If another request comes along looking for the same key while that's happening, it doesn't execute the delegate again. It waits for the first request to finish and uses the result.


So instead of:

Request 1 -> Cache miss -> GetValue()
Request 2 -> Cache miss -> GetValue()
Request 3 -> Cache miss -> GetValue()
Request 4 -> Cache miss -> GetValue()

you essentially get:

Request 1 -> Cache miss -> GetValue() -> Cache populated

Request 2 -> Wait -> Cached value
Request 3 -> Wait -> Cached value
Request 4 -> Wait -> Cached value

It's protection against what's commonly referred to as a cache stampede, and Optimizely has support for it built into an API that, until this point, I didn't even know existed!


Then I found master keys

While looking into ReadThrough, I also ended up going further down the caching rabbit hole and looking at the CacheEvictionPolicy being passed into it.


This is the implementation I ended up with:

var cachedAdminPage = _synchronizedObjectInstanceCache.ReadThrough(
SynchronizedObjectCacheKeyConstants.IpRestrictionsCacheKey,
() => {
var someExpensiveCollection = new Dictionary<string, string>()
{
// Omitted for brevity
};

return someExpensiveCollection;
},
evictionPolicy: fields =>
GetCacheEvictionPolicy(adminSiteSettingsPage, _contentCacheKeyCreator),
readStrategy: ReadStrategy.Wait);

The data itself isn't particularly important, but what is important is what happens when somebody changes that page.


Typically, my first thought would normally be to give the cached value an expiry; for example, five minutes.


A fixed time-to-live is fine, but it also means somebody could publish a change in the CMS and potentially have to wait another five minutes before the application starts using it.

To work around this, you could listen for a publish event and manually remove the cache entry, but that feels like I'm creating another bit of code whose sole responsibility is keeping my cache in sync with Optimizely, and then maintaining that - it just feels overkill for a five minute cache.


The good news is, that Optimizely already knows the content has changed, and it turns out that we can use that to our advantage!


The eviction policy for this cache looks like this:

private static CacheEvictionPolicy GetCacheEvictionPolicy(
ContentPage contentPage,
IContentCacheKeyCreator contentCacheKeyCreator)
{
var contentCacheKey = contentCacheKeyCreator.CreateCommonCacheKey(
contentPage.ContentLink.ToReferenceWithoutVersion());

var cacheEvictionPolicy = new CacheEvictionPolicy(
expiration: CacheDuration,
timeoutType: CacheTimeoutType.Absolute,
cacheKeys: null,
masterKeys: [contentCacheKey]);

return cacheEvictionPolicy;
}

There are two lines in here that I hadn't really paid much attention to before.


The first:

contentCacheKeyCreator.CreateCommonCacheKey(
contentPage.ContentLink.ToReferenceWithoutVersion());

And then:

masterKeys: [contentCacheKey]

IContentCacheKeyCreator allows us to get the cache key Optimizely uses to represent the content.


Rather than creating my own cache invalidation based on the content ID, I can use Optimizely's existing one.


The masterKeys property on CacheEvictionPolicy then allows my cache entry to depend on that key.


This was probably the bit where the penny dropped for me, my custom cache entry isn't just cached for five minutes anymore; it's now tied to the Optimizely content that the data came from.


If the page doesn't change, the absolute expiration still applies and eventually the cached value is removed as normal, but if somebody changes and publishes the page first, Optimizely invalidates the associated content cache and my dependent cache entry is invalidated along with it.


This means:

1. No custom publish event.

2. No manual call to Remove.

3. No waiting around wondering why the change you've just published hasn't done anything.


Why CreateCommonCacheKey matters


You could technically create your own master key. In fact, when I first started looking at masterKeys, that's probably what I would have done.


Something along the lines of:

masterKeys: [$"AdminSettings:{adminPage.ContentLink.ID}"]

But then something still needs to remove that master key when the content changes for example, via an event listener, and that's the important difference.


By using:

_contentCacheKeyCreator.CreateCommonCacheKey(
contentReference.ToReferenceWithoutVersion())

I'm not inventing another cache invalidation mechanism. Instead, I'm attaching my cached value to the one Optimizely is already maintaining.


It's important to flag that I also specifically don't want the versioned ContentReference here.

The data I'm caching isn't tied to version 42 of that particular page, I just care about the page itself. When the next version gets published, the data I've cached from version 42 is immediately something I don't want anymore.


Using the common cache key for the versionless reference gives me that relationship.

There is also a nice side effect of using ISynchronizedObjectInstanceCache here rather than treating this as a normal local memory cache. Optimizely's synchronized cache is designed to propagate cache removals between instances, which becomes particularly important in DXP where the application isn't necessarily running on a single instance.


Without Optimizely implementing propagated cache removals between instances, invalidating the cache on one instance doesn't help much if the next request happens to land on another instance that still has the old value.


It is worth being precise here: ISynchronizedObjectInstanceCache is synchronising cache removals between instances, not turning the cache itself into a shared distributed cache.


Because of the work done by Optimizely, the whole implementation ends up being relatively small:

_synchronizedObjectInstanceCache.ReadThrough(
cacheKey,
valueFactory,
evictionPolicy,
ReadStrategy.Wait);

But, there's a surprising amount hidden behind it.

  1. ReadThrough takes care of the usual cache-aside plumbing.
  2. ReadStrategy.Wait prevents a load of concurrent requests from all doing the same work when that cache is empty
  3. masterKeys lets the cached value depend on something else being cached.
  4. Finally, IContentCacheKeyCreator.CreateCommonCacheKey() gives us a way of making that dependency the Optimizely content the data actually came from.


One important thing to flag is that this invalidation approach only works with CMS content. After confirming this with Optimizely support, it won't work for something like a DAM asset because publishing or updating an asset in CMP/DAM doesn't trigger the same internal CMS event.


As a result, the cache key we're depending on isn't invalidated, meaning our cached value would remain until its normal expiry.


I think the bit I like most is that none of this requires me to invent another abstraction on top of Optimizely; instead, allowing me to focus on the current feature that I'm working on.


Maybe ReadThrough and master keys are common knowledge and I'm incredibly late to the party, but considering how little I managed to find about them when I went looking, I suspect I'm probably not the only one.

Either way, that's another under-documented Optimizely feature added to the list.



Dom Reilly

Written by

Dom Reilly

Senior Software Developer and Tech Lead working with .NET and Blazor. Sharing real-world problems, tech, and lessons learned - not always just development. Views expressed here are my own and do not represent my employer.

Keep reading

19 Years Later: The Undocumented Optimizely Category Inheritance That Caught Me Out

While working on an Optimizely CMS 12 project, I encountered an unexpected issue where categories assigned to a parent page were automatically inherited by newly created child pages. After ruling out custom code and...

Posted: 20 Mar 2026
OptimizelyOptimizely CMS
Read more
The Architecture Behind the Blog: From Zero to Production

What started as an all in one Blazor blog gradually evolved into something quite different. From moving the data layer to MySQL and rebuilding the backend in Go, to Dockerising the applications and putting Caddy in...

Posted: 10 Aug 2026
APIArchitectureBlazorBlogdockerAnd 4 more
Read more