Mardy
Opal Tools CertificationOpal Administrator Certificationworkspace_premium +4
Oct 19, 2026
visibility 33
star star star star star
(0 votes)

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:

  1. Enables inclusion of the cursor in the GraphQL response.
  2. 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.

References

Oct 19, 2026

Comments

error Please login to comment.
Latest blogs
EPiServer.ImageLibrary.ImageSharp 2.0.7 delisted from NuGet feed

We have delisted version 2.0.7 of the EPiServer.ImageLibrary.ImageSharp package from the NuGet feed due to a packaging issue. This version was...

Phu Nguyen | Oct 19, 2026

Surviving a Dirty Feed. Part 3: Error Handling and Event Batching

Committing a 250,000-row Optimizely catalog import row by row, returning a per-row failure report, and coalescing catalog events into one per batch.

Stanisław Szołkowski | Oct 16, 2026 |

Building UI Extensions for Optimizely SaaS CMS

Introduction Developers have been extending the functionality of Optimizely PaaS CMS for years using addons built in C# and deployed as NuGet...

Mark Stott | Oct 15, 2026