Exploring Cached Templates and Cursor Pagination in the Optimizely Graph C# SDK
While experimenting with the Optimizely Graph C# SDK (13.3.0) in my local Optimizely CMS 13 Alloy project, I came across two interesting findings: how cached templates are handled by the SDK, and how cursor-based pagination differs between typed and untyped queries.
I used ILSpy to inspect the SDK implementation and Fiddler to verify the outgoing HTTP requests.
Here are my findings.
1. Cached Templates: What Does Enabled by Default Mean?
The Optimizely Graph cached templates documentation includes the following note:
Cached templates are enabled by default for queries created on or after January 1, 2025.
This caught my attention while testing queries through the C# SDK.
For example, consider this query:
var query = _graphClient
.QueryContent<ISearchableContent>()
.Limit(5)
.IncludeTotal()
.Fields<SearchResult>(x => x.SearchTitle);
var result = await query.GetAsync();
After inspecting the outgoing request using Fiddler, I noticed that the SDK sends a request to:
POST https://cg.optimizely.com/content/v2
However, the request didn't include either of the parameters documented for explicitly enabling cached templates:
?stored=true
cg-stored-query: template
Looking Inside the SDK
Using ILSpy, I inspected the request construction inside Optimizely.Graph.Core.Client.GraphClient.
The relevant code looked like this:
var fullQuery = new { query, variables };
var request = new HttpRequestMessage(
HttpMethod.Post,
allowCachedResults
? "content/v2"
: "content/v2?cache=false")
{
Content = JsonContent.Create(fullQuery)
};
AddCustomHeaders(request, customHeaders);
In the execution path I inspected, the SDK didn't explicitly append stored=true or add the cg-stored-query header.
Does this mean cached templates aren't enabled?
Not necessarily.
The documentation describes default behavior, which may be handled by the Graph service itself without requiring these parameters from the client.
My observation confirms only that the SDK doesn't explicitly send them in this scenario.
Since the SDK communicates with the hosted Graph service, I wouldn't assume that running CMS 13 locally explains the difference.
This remains an interesting point to clarify.
2. Explicitly Enabling Cached Templates with a DelegatingHandler
To experiment with explicit cached-template requests without modifying the SDK package, I used a custom DelegatingHandler.
The idea was straightforward: intercept outgoing Graph requests and add the required query parameter and header.
Custom HTTP Handler
public sealed class GraphCachedTemplateHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (request.Method == HttpMethod.Post &&
request.RequestUri?.AbsolutePath.TrimEnd('/') == "/content/v2")
{
var uri = new UriBuilder(request.RequestUri);
var query = uri.Query.TrimStart('?');
uri.Query = string.IsNullOrEmpty(query)
? "stored=true"
: $"{query}&stored=true";
request.RequestUri = uri.Uri;
request.Headers.TryAddWithoutValidation(
"cg-stored-query", "template");
}
return base.SendAsync(request, cancellationToken);
}
}
Registering the Handler
After registering the Graph SDK, I attached the handler to its named HTTP client:
services.AddContentGraph();
services.AddTransient<GraphCachedTemplateHandler>();
services
.AddHttpClient("Optimizely.Graph.Core.Client.Authenticated")
.AddHttpMessageHandler<GraphCachedTemplateHandler>();
Verifying with Fiddler
After executing the same Graph query, I confirmed the outgoing request:
POST https://cg.optimizely.com/content/v2?stored=true
cg-stored-query: template
This demonstrated that I could explicitly request cached-template behavior without changing the SDK source code.
Important: This handler applies to matching requests made through the configured HTTP client. It is not a per-query opt-in.
What About a Fluent WithCachedTemplate() Extension?
I also explored adding a custom extension method to support syntax such as:
var query = _graphClient
.QueryContent<ISearchableContent>()
.Limit(5)
.WithCachedTemplate()
.Fields<SearchResult>(x => x.SearchTitle);
However, there's an important limitation.
The SDK's existing fluent methods, such as WithoutCache(), update internal query state that is later used during execution.
Although a custom WithCachedTemplate() extension can store metadata against a query, the SDK's existing GetAsync() doesn't automatically read that metadata or propagate it to the HTTP layer.
As a result, the extension method can compile and fit naturally into the fluent API, but it doesn't change the outgoing HTTP request.
The custom DelegatingHandler demonstrated earlier works at the HTTP-client level, but it cannot determine which individual query opted into cached templates without additional integration.
A proper per-query implementation would therefore require an execution wrapper or, ideally, native SDK support to propagate the setting through the execution pipeline.
3. Cursor-Based Pagination: Typed vs. Untyped Queries
The second finding came while experimenting with cursor-based pagination.
Initially, I tried to use SetCursor() with the strongly typed query API:
var query = _graphClient
.QueryContent<ISearchableContent>()
.SetCursor(null);
However, this doesn't compile because SetCursor() isn't exposed through the typed ISearchableContentQuery<T> interface in the SDK version I tested.
After inspecting the implementation, I found that SetCursor() is available through the untyped query API:
_graphClient.Query("ISearchableContent")
First Page
var query = _graphClient
.Query("ISearchableContent")
.SetCursor(null)
.Limit(5)
.IncludeTotal()
.Fields("SearchTitle");
var result = await query.GetAsync();
Passing null enables the cursor field without specifying a previous cursor.
Next Page
For subsequent pages, the cursor returned by the previous response can be passed to SetCursor():
var query = _graphClient
.Query("ISearchableContent")
.SetCursor(nextCursor)
.Limit(5)
.Fields("SearchTitle");
var result = await query.GetAsync();
Here, nextCursor represents the cursor obtained from the previous response.
Both requests use the same page size and content selection. The cursor value is null for the initial request and the cursor returned by the previous response for subsequent requests.
Looking Inside SetCursor()
The implementation is straightforward:
public IQuery SetCursor(string? cursor)
{
_queryExpression.IncludeCursor = true;
_queryExpression.Cursor = cursor;
return this;
}
It performs two operations:
- Enables inclusion of the cursor in the GraphQL response.
- Assigns the supplied cursor for pagination.
This explains why SetCursor(null) is useful for the initial request.
An SDK API Difference
The distinction is worth highlighting:
| Query API | Cursor support |
|---|---|
| QueryContent<T>() | SetCursor() not exposed |
| Query("TypeName") | SetCursor() available |
This is a difference in the SDK's public fluent interfaces, rather than evidence that the Graph service doesn't support cursor pagination for typed content.
4. Final Thoughts
These experiments helped me better understand how the Optimizely Graph C# SDK builds and executes queries.
A few takeaways:
- The SDK doesn't explicitly send cached-template parameters in the execution path I inspected, even though the documentation describes cached templates as enabled by default for newer queries.
- A custom DelegatingHandler can add the documented parameters to outgoing Graph requests.
- A fluent per-query WithCachedTemplate() extension requires more than storing a flag; the setting must reach the HTTP execution layer.
- Cursor pagination is exposed through the untyped Query("TypeName") API, while the typed QueryContent<T>() interface doesn't expose SetCursor() in version 13.3.0.
These are relatively small observations, but they illustrate why exploring the SDK implementation and inspecting actual HTTP traffic can be useful when working with Graph.
I'd be interested to hear from the Optimizely Graph team how the documented default behavior for cached templates applies to queries executed through the C# SDK, particularly when the outgoing requests don't explicitly include the cached-template parameters.
Comments