Caching Guide

April 27, 2026 · View on GitHub

Caching is essential for production applications using the Kontent.ai Delivery API. This guide covers all aspects of implementing effective caching strategies, from basic memory caching to sophisticated webhook-based invalidation.

Table of Contents

Why Caching Matters

API Rate Limits

Kontent.ai enforces rate limits on API requests:

  • Without caching, you can quickly hit these limits
  • Repeated requests for the same content waste quota
  • Caching dramatically reduces API calls

Performance

  • Reduced Latency: Cached responses are served in microseconds vs. milliseconds for API calls
  • Lower Bandwidth: No network round-trip for cached content
  • Better User Experience: Faster page loads and responses

Cost Optimization

  • Fewer API calls mean lower costs in high-traffic scenarios
  • Reduced infrastructure requirements for handling API responses
  • Better resource utilization

Cache Types

Memory Cache

Pros:

  • Fastest possible cache access (microseconds)
  • No external dependencies
  • Simple setup

Cons:

  • Limited to single server (not shared across instances)
  • Memory pressure on large datasets
  • Lost on application restart

When to Use:

  • Single-server deployments
  • Development and testing
  • Low to moderate traffic applications

Hybrid Cache

Pros:

  • Shared across multiple application instances
  • Survives application restarts
  • Scalable to large datasets
  • Can be managed independently

Note: The SDK stores raw JSON payloads in hybrid caches and rehydrates on read. This avoids circular reference serialization issues and keeps payloads portable across instances.

Built-in hybrid cache invalidation uses dependency tags through FusionCache. If a FusionCache backplane is configured, invalidations are propagated to other nodes to keep local caches coherent.

Note

FusionCache hybrid mode limitation: When using hybrid caching, FusionCache operates in hybrid (L1+L2) mode but currently stores the same serialized format in both layers. This means the L1 memory layer also holds raw JSON rather than hydrated objects, so every cache hit goes through rehydration. For most workloads the rehydration cost is negligible. If your scenario demands maximum read throughput, use AddDeliveryMemoryCache (pure L1, hydrated objects, no rehydration overhead).

Cons:

  • Network latency (still faster than API calls)
  • Requires external infrastructure (Redis, SQL Server, etc.)
  • Additional configuration complexity

When to Use:

  • Production environments with multiple servers
  • High-availability requirements
  • Large-scale applications
  • Cloud deployments

Configuration

Caching is provided by the standalone Kontent.Ai.Delivery.Caching package:

dotnet add package Kontent.Ai.Delivery.Caching

This package provides AddDeliveryMemoryCache, AddDeliveryHybridCache, AddDeliveryCacheManager DI extension methods and DeliveryClientBuilder.WithMemoryCache() / .WithHybridCache() extension methods. All are FusionCache-backed while keeping the same public IDeliveryCacheManager contract.

Memory Cache Setup

Basic Configuration

using Kontent.Ai.Delivery;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

// Single client scenario - no name required
services.AddDeliveryClient(options =>
{
    options.EnvironmentId = "your-environment-id";
});
services.AddDeliveryMemoryCache(defaultExpiration: TimeSpan.FromHours(1));

var serviceProvider = services.BuildServiceProvider();

Named Clients

For multi-client scenarios, use named clients:

services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

services.AddDeliveryMemoryCache("production", defaultExpiration: TimeSpan.FromMinutes(30));

Advanced Memory Cache Configuration

services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024;  // Limit cache size
    options.CompactionPercentage = 0.25;  // Remove 25% when limit hit
});

services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

services.AddDeliveryMemoryCache("production", defaultExpiration: TimeSpan.FromHours(1));

Hybrid Cache Setup

Redis Cache

using StackExchange.Redis;

// Register Redis distributed cache
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
    options.InstanceName = "KontentCache_";
});

// Single client scenario
services.AddDeliveryClient(options =>
{
    options.EnvironmentId = "your-environment-id";
});
services.AddDeliveryHybridCache(defaultExpiration: TimeSpan.FromHours(2));

// Or with named clients for multi-client scenarios:
// services.AddDeliveryClient("production", options => { ... });
// services.AddDeliveryHybridCache("production", defaultExpiration: TimeSpan.FromHours(2));

Redis with Connection Multiplexer

// Register Redis connection
services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var configuration = ConfigurationOptions.Parse("localhost:6379");
    configuration.AbortOnConnectFail = false;
    configuration.ConnectTimeout = 5000;
    return ConnectionMultiplexer.Connect(configuration);
});

services.AddStackExchangeRedisCache(options =>
{
    options.ConnectionMultiplexerFactory = async () =>
        await Task.FromResult(serviceProvider.GetRequiredService<IConnectionMultiplexer>());
    options.InstanceName = "Kontent_";
});

services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

services.AddDeliveryHybridCache("production", defaultExpiration: TimeSpan.FromHours(4));

SQL Server Distributed Cache

services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = configuration.GetConnectionString("CacheDb");
    options.SchemaName = "dbo";
    options.TableName = "KontentCache";
});

services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

services.AddDeliveryHybridCache("production", defaultExpiration: TimeSpan.FromHours(1));

Azure Cache for Redis

services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = configuration.GetConnectionString("AzureRedis");
    options.InstanceName = "Production_Kontent_";
});

services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

services.AddDeliveryHybridCache("production", defaultExpiration: TimeSpan.FromHours(6));

Configuring Cache Options from DI Services

Use the IServiceProvider cache overloads when cache settings need to be composed from other registered services, such as application options bound from configuration:

services.Configure<SiteOptions>(configuration.GetSection("Site"));

services.AddDeliveryClient("production", (sp, options) =>
{
    var site = sp.GetRequiredService<IOptions<SiteOptions>>().Value;
    options.EnvironmentId = site.EnvironmentId;
});

services.AddDeliveryMemoryCache("production", (sp, options) =>
{
    var site = sp.GetRequiredService<IOptions<SiteOptions>>().Value;
    options.DefaultExpiration = site.CacheExpiration;
    options.IsFailSafeEnabled = true;
});

Timing matters: the plain Action<DeliveryCacheOptions> overloads run immediately during service registration and validate cache options immediately. The (IServiceProvider, DeliveryCacheOptions) overloads run later, when the keyed singleton IDeliveryCacheManager is first resolved from the root provider, so validation is deferred to that first resolution.

Because the cache manager is a singleton, resolve only singleton-safe dependencies from cache callbacks, such as IOptions<T>, IOptionsMonitor<T>, configuration, or loggers. Do not depend on scoped/request services such as IOptionsSnapshot<T>, DbContext, tenant request context, or per-request HttpContext state.

Custom Cache Manager

For advanced scenarios, implement a custom cache manager. The IDeliveryCacheManager interface uses a factory-based GetOrSetAsync pattern — the factory is invoked on cache miss and returns a CacheEntry<T>? (null signals "don't cache"). The method returns CacheResult<T>? (a record containing the Value and the collected DependencyKeys) so that downstream consumers can access dependency metadata.

Use the default StorageMode (CacheStorageMode.HydratedObject) for hydrated-object caching (memory), or override StorageMode to CacheStorageMode.RawJson for raw JSON payload caching (distributed).

Hydrated-object cache manager (memory style)

using System.Collections.Concurrent;
using Kontent.Ai.Delivery.Abstractions;

public class CustomMemoryCacheManager : IDeliveryCacheManager
{
    private readonly ConcurrentDictionary<string, object> _cache = new();

    public async Task<CacheResult<T>?> GetOrSetAsync<T>(
        string cacheKey,
        Func<CancellationToken, Task<CacheEntry<T>?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default) where T : class
    {
        if (_cache.TryGetValue(cacheKey, out var cached))
            return new CacheResult<T>((T)cached, []);

        var entry = await factory(cancellationToken);
        if (entry is null) return null;

        _cache.TryAdd(cacheKey, entry.Value);
        return new CacheResult<T>(entry.Value, entry.Dependencies.ToArray());
    }

    public Task<bool> InvalidateAsync(string[] dependencyKeys, CancellationToken cancellationToken = default)
    {
        // Implement dependency tracking + invalidation for production use
        return Task.FromResult(true);
    }
}

Raw JSON cache manager (hybrid style)

using System.Text.Json;
using Kontent.Ai.Delivery.Abstractions;
using Microsoft.Extensions.Caching.Distributed;

public class CustomHybridCacheManager : IDeliveryCacheManager
{
    private readonly IDistributedCache _cache;

    public CustomHybridCacheManager(IDistributedCache cache) => _cache = cache;

    // Tell the SDK to cache raw JSON payloads instead of hydrated objects
    public CacheStorageMode StorageMode => CacheStorageMode.RawJson;

    public async Task<CacheResult<T>?> GetOrSetAsync<T>(
        string cacheKey,
        Func<CancellationToken, Task<CacheEntry<T>?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default) where T : class
    {
        var json = await _cache.GetStringAsync(cacheKey, cancellationToken);
        if (json is not null)
            return new CacheResult<T>(JsonSerializer.Deserialize<T>(json)!, []);

        var entry = await factory(cancellationToken);
        if (entry is null) return null;

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromHours(1)
        };

        var serialized = JsonSerializer.Serialize(entry.Value);
        await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken);

        // Implement dependency index + invalidation for production use
        return new CacheResult<T>(entry.Value, entry.Dependencies.ToArray());
    }

    public Task<bool> InvalidateAsync(string[] dependencyKeys, CancellationToken cancellationToken = default)
        => Task.FromResult(true);
}

// Registration
services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});
services.AddDeliveryCacheManager("production",
    sp => new CustomHybridCacheManager(sp.GetRequiredService<IDistributedCache>()));

How Caching Works

Cache Coverage

SDK caching applies to cacheable query builders (for example, strongly-typed item/list queries and type/taxonomy queries).

Dynamic item/list queries (GetItem() and GetItems() without a generic model type) are intentionally non-cacheable because their final item types are resolved at runtime. These queries always execute against the API and return IsCacheHit == false.

When WaitForLoadingNewContent(true) is enabled for a query, the SDK bypasses local caching for that request path (no cache lookup and no cache store).

When a client is configured with UsePreviewApi = true, the SDK always bypasses local cache reads/writes for that client, even if a cache manager is registered.

Cache Keys

Cache keys are automatically generated from query parameters using a deterministic, human-readable format.

Key Format

The general format is: {queryType}:{identifier}:{params}:{filters}

Query TypeFormatExample
Single Itemitem:{codename}:lang={lang}:depth={n}:elements={sorted}item:homepage:lang=en-US:depth=2
List Itemsitems:lang={lang}:depth={n}:skip={n}:limit={n}:filters={hash}items:lang=en-US:skip=0:limit=10
Single Typetype:{codename}:elements={sorted}type:article:elements=name|codename
List Typestypes:skip={n}:limit={n}:elements={sorted}:filters={hash}types:skip=0:limit=25
Single Taxonomytaxonomy:{codename}taxonomy:categories
List Taxonomiestaxonomies:skip={n}:limit={n}:filters={hash}taxonomies:skip=0:limit=100

Key Properties

  • Deterministic: Same parameters always produce the same key
  • Order-independent: Arrays and filter dictionaries in different orders produce the same key
  • Human-readable: Common parameters are visible for debugging (e.g., lang=en-US:depth=2)
  • Efficient: Filters are hashed to keep keys compact when queries are complex

Examples

// Single item query
await client.GetItem<Article>("homepage").ExecuteAsync();
// Key: item:homepage

// Item with language and depth
await client.GetItem<Article>("homepage")
    .WithLanguage("de-DE")
    .Depth(2)
    .ExecuteAsync();
// Key: item:homepage:lang=de-DE:depth=2

// Item with element projection
await client.GetItem<Article>("homepage")
    .WithElements("title", "description")
    .ExecuteAsync();
// Key: item:homepage:elements=description|title  (sorted alphabetically)

// Items listing with pagination
await client.GetItems<Article>()
    .Skip(10)
    .Limit(5)
    .ExecuteAsync();
// Key: items:skip=10:limit=5

// Items with filters (filters are hashed for brevity)
await client.GetItems<Article>()
    .Where(f => f.System("type").IsEqualTo("article"))
    .Where(f => f.Element("category").IsIn("news", "blog"))
    .ExecuteAsync();
// Key: items:filters=A7F3E2B9C1D5  (12-char hash of sorted filter parameters)

// Taxonomy query
await client.GetTaxonomy("categories").ExecuteAsync();
// Key: taxonomy:categories

Filter Hashing

When queries include filters, they are hashed using SHA256 (first 12 characters of URL-safe base64):

  • Filters are sorted by key, then by value before hashing
  • This ensures {("a", "1"), ("b", "2")} and {("b", "2"), ("a", "1")} produce the same hash
  • The 12-character hash provides ~72 bits of entropy (extremely low collision probability)

Key Prefixing

Default (single-client) scenario:

services.AddDeliveryClient(o => o.EnvironmentId = "...");
services.AddDeliveryMemoryCache();
// Keys have NO prefix: item:homepage, items:skip=0:limit=10, etc.

Named clients (multi-client scenario):

services.AddDeliveryClient("production", o => o.EnvironmentId = "...");
services.AddDeliveryMemoryCache("production");
// Keys are prefixed with client name: production:item:homepage, etc.

Custom prefix:

services.AddDeliveryMemoryCache("production", keyPrefix: "prod");
// Keys become: prod:item:homepage, prod:items:skip=0:limit=10, etc.

No prefix (explicit):

services.AddDeliveryMemoryCache("production", keyPrefix: "");
// Keys have no prefix even for named clients

This prevents cache collisions when multiple clients share the same underlying cache.

EnvironmentId and DefaultRenditionPreset are not part of query cache keys. Use separate named clients (or distinct key prefixes) per environment/configuration. If you change either option at runtime on an existing cached client, purge cache (or recreate the client) to avoid serving older entries.

Dependency Tracking

The SDK automatically tracks content dependencies for every query. These dependency keys serve two purposes: they drive internal cache tag-based invalidation, and they are surfaced on the delivery result itself (via IDeliveryResult<T>.DependencyKeys) so that your application can use them for downstream caching scenarios such as ASP.NET output-cache tagging.

// When you retrieve an article with linked authors
var result = await client.GetItem<Article>("my-article")
    .Depth(2)
    .ExecuteAsync();

// The following dependencies are tracked:
// - item_my-article
// - item_author1 (if linked)
// - item_author2 (if linked)
// - type_article (+ type_{codename} for each linked item's content type)
// - Any assets used in the content

This enables targeted cache invalidation when specific content changes.

Cached GetItems<T>() queries also include a synthetic scope dependency:

  • DeliveryCacheDependencies.ItemsListScope (scope_items_list)

Use this key when an item event may change which cached lists an item belongs to (for example, new item publish or metadata update). Invalidating the scope key clears all cached typed item-list queries in the current cache namespace.

Cached GetTypes() and GetTaxonomies() queries use the same pattern:

  • DeliveryCacheDependencies.TypesListScope (scope_types_list) for type listings
  • DeliveryCacheDependencies.TaxonomiesListScope (scope_taxonomies_list) for taxonomy listings

Single type queries use direct keys in the format type_{codename} (for example, type_article). The same type_{codename} key is also attached to every cached item / item-list query whose payload references at least one item of that type (including linked items, modular content, and rich-text inline items). Invalidating type_article therefore evicts both the cached type definition and any item caches referencing articles — the recommended signal for content-type-change webhooks.

Using Dependency Keys for Output Caching

The SDK exposes dependency keys on every delivery result via the DependencyKeys property on IDeliveryResult<T>. This enables downstream cache invalidation scenarios such as ASP.NET output-cache tagging — you can tag your controller-level or page-level cache entries with the same keys the SDK uses internally.

var result = await client.GetItem<Article>("my-article").ExecuteAsync();

if (result.IsSuccess && result.DependencyKeys is { } keys)
{
    // Tag your output cache entry with the SDK's dependency keys
    foreach (var key in keys)
    {
        HttpContext.Response.Headers.Append("Cache-Tag", key);
    }
}

Dependency keys are always available — they are collected regardless of whether SDK caching is configured. The key formats are the same canonical formats used for SDK cache invalidation (see Invalidation Matrix).

Expiration Strategies

Absolute Expiration

Cache entries expire after a fixed duration:

services.AddDeliveryClient("production", options => { ... });
services.AddDeliveryMemoryCache("production", defaultExpiration: TimeSpan.FromHours(2));

Sliding Expiration

For custom cache managers, you can implement sliding expiration inside your GetOrSetAsync factory by configuring the underlying IDistributedCache entry options:

// In a custom IDeliveryCacheManager.GetOrSetAsync implementation:
var options = new DistributedCacheEntryOptions
{
    SlidingExpiration = expiration  // Renewed on each access
};
await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken);

Per-query Expiration Override

You can override TTL for a specific cacheable query without changing the cache manager default:

var itemResult = await client.GetItem<Article>("my-article")
    .WithCacheExpiration(TimeSpan.FromMinutes(5))
    .ExecuteAsync();

var listResult = await client.GetItems<Article>()
    .WithCacheExpiration(TimeSpan.FromMinutes(2))
    .ExecuteAsync();

Supported cacheable query builders:

  • GetItem<T>()
  • GetItems<T>()
  • GetType()
  • GetTypes()
  • GetTaxonomy()
  • GetTaxonomies()

Cache Invalidation

Invalidation Matrix (RC-ready)

Use this matrix when mapping webhook events to SDK dependency invalidation keys:

Endpoint familyDetail dependency keyListing scope dependency key
Itemsitem_{codename}DeliveryCacheDependencies.ItemsListScope (scope_items_list)
Typestype_{codename} (also tags item/item-list caches containing items of that type)DeliveryCacheDependencies.TypesListScope (scope_types_list)
Taxonomiestaxonomy_{codename}DeliveryCacheDependencies.TaxonomiesListScope (scope_taxonomies_list)

Recommended webhook pattern:

  • item event: invalidate item_{codename} + scope_items_list
  • type event: invalidate type_{codename} + scope_types_list — the type_{codename} key now covers both the cached type definition and every item/item-list cache whose payload references items of that type, so content-type changes or deletions no longer require falling back to scope_items_list
  • taxonomy event: invalidate taxonomy_{codename} + scope_taxonomies_list

Manual Invalidation

Invalidate specific content:

using Kontent.Ai.Delivery.Abstractions;
using Microsoft.Extensions.DependencyInjection;

var cacheManager = serviceProvider.GetRequiredKeyedService<IDeliveryCacheManager>("production");

// Invalidate a specific item
await cacheManager.InvalidateAsync(["item_homepage"]);

// Invalidate multiple items
await cacheManager.InvalidateAsync([
    "item_article1",
    "item_article2",
    "taxonomy_categories"]);

// Invalidate by dependency
await cacheManager.InvalidateAsync([$"item_{articleCodename}"]);

// Invalidate a specific type query dependency
await cacheManager.InvalidateAsync(["type_article"]);

// Invalidate all cached typed item-list queries
await cacheManager.InvalidateAsync([DeliveryCacheDependencies.ItemsListScope]);

// Invalidate all cached type/taxonomy listing queries
await cacheManager.InvalidateAsync([DeliveryCacheDependencies.TypesListScope]);
await cacheManager.InvalidateAsync([DeliveryCacheDependencies.TaxonomiesListScope]);

Purge All (SDK Cache)

Sometimes you need to invalidate everything at once (e.g., after a deployment, emergency rollback, or a major content model change).

The SDK exposes an optional capability interface IDeliveryCachePurger that is implemented by built-in cache managers.

Note

If you're using a custom cache manager that does not implement IDeliveryCachePurger, use provider-specific purge tooling or key-prefix rotation.

using Kontent.Ai.Delivery.Abstractions;
using Microsoft.Extensions.DependencyInjection;

var cacheManager = serviceProvider.GetRequiredKeyedService<IDeliveryCacheManager>("production");

if (cacheManager is IDeliveryCachePurger purger)
{
    // Permanently remove all entries (default behavior)
    await purger.PurgeAsync();

    // Or: mark entries as logically expired, preserving fail-safe fallback data
    await purger.PurgeAsync(allowFailSafe: true);
}

Webhook-Based Invalidation

Implement automatic cache invalidation using Kontent.ai webhooks:

1. Webhook Controller

using Kontent.Ai.Delivery.Abstractions;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/webhooks")]
public class WebhookController : ControllerBase
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<WebhookController> _logger;

    public WebhookController(
        IServiceProvider serviceProvider,
        ILogger<WebhookController> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    [HttpPost("kontent")]
    public async Task<IActionResult> HandleWebhook([FromBody] WebhookNotification notification)
    {
        // Verify webhook signature (recommended)
        if (!VerifySignature(Request.Headers["X-KC-Signature"]))
        {
            return Unauthorized();
        }

        try
        {
            await ProcessWebhookAsync(notification);
            return Ok();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Webhook processing failed");
            return StatusCode(500);
        }
    }

    private async Task ProcessWebhookAsync(WebhookNotification notification)
    {
        var cacheManager = _serviceProvider.GetRequiredKeyedService<IDeliveryCacheManager>("production");
        var dependencies = new List<string>();

        foreach (var item in notification.Data.Items)
        {
            // Content item changes affect item queries and item listings.
            if (item.Type == "content_item")
            {
                dependencies.Add($"item_{item.Codename}");
                dependencies.Add(DeliveryCacheDependencies.ItemsListScope);
            }

            // Taxonomy changes affect taxonomy queries and taxonomy listings.
            if (item.Type == "taxonomy")
            {
                dependencies.Add($"taxonomy_{item.Codename}");
                dependencies.Add(DeliveryCacheDependencies.TaxonomiesListScope);
            }

            // Content type changes affect type queries and type listings.
            if (item.Type == "content_type")
            {
                dependencies.Add($"type_{item.Codename}");
                dependencies.Add(DeliveryCacheDependencies.TypesListScope);
            }
        }

        // Invalidate all affected cache entries
        await cacheManager.InvalidateAsync(dependencies.ToArray());

        _logger.LogInformation(
            "Invalidated {Count} cache entries from webhook",
            dependencies.Count);
    }

    private bool VerifySignature(string signature)
    {
        // Implement webhook signature verification
        // See: https://kontent.ai/learn/docs/webhooks/validate-webhooks
        return true;
    }
}

public class WebhookNotification
{
    public WebhookData Data { get; set; }
    public WebhookMessage Message { get; set; }
}

public class WebhookData
{
    public List<WebhookItem> Items { get; set; }
}

public class WebhookItem
{
    public string Id { get; set; }
    public string Codename { get; set; }
    public string Type { get; set; }
}

public class WebhookMessage
{
    public string Id { get; set; }
    public string Type { get; set; }
    public string Operation { get; set; }
}

2. Webhook Signature Verification

using System.Security.Cryptography;
using System.Text;

private bool VerifyWebhookSignature(string signature, string requestBody, string secret)
{
    if (string.IsNullOrEmpty(signature))
        return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(requestBody));
    var computedSignature = Convert.ToBase64String(hash);

    return signature == computedSignature;
}

[HttpPost("kontent")]
public async Task<IActionResult> HandleWebhook()
{
    using var reader = new StreamReader(Request.Body);
    var body = await reader.ReadToEndAsync();

    var signature = Request.Headers["X-KC-Signature"].FirstOrDefault();
    var secret = _configuration["Kontent:WebhookSecret"];

    if (!VerifyWebhookSignature(signature, body, secret))
    {
        _logger.LogWarning("Invalid webhook signature");
        return Unauthorized();
    }

    var notification = JsonSerializer.Deserialize<WebhookNotification>(body);
    await ProcessWebhookAsync(notification);

    return Ok();
}

3. Configure Webhook in Kontent.ai

  1. Go to Environment Settings > Webhooks
  2. Create a new webhook
  3. Set URL to: https://yourapp.com/api/webhooks/kontent
  4. Select events: "Publish", "Unpublish", "Archive"
  5. Save the webhook secret for signature verification

Timed Invalidation

For content that changes on a schedule:

public class CacheInvalidationService : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<CacheInvalidationService> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var cacheManager = _serviceProvider.GetRequiredKeyedService<IDeliveryCacheManager>("production");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // Invalidate every cached items listing every 5 minutes.
                // Use DeliveryCacheDependencies.ItemsListScope ("scope_items_list")
                // to drop ALL items-list responses in one call. To target a single item,
                // use $"item_{codename}" instead.
                await cacheManager.InvalidateAsync(
                    [DeliveryCacheDependencies.ItemsListScope], stoppingToken);

                // Wait 5 minutes
                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Cache invalidation failed");
            }
        }
    }
}

// Register service
services.AddHostedService<CacheInvalidationService>();

Per-Client Caching

The SDK supports per-client cache configuration using keyed services, allowing different named clients to have independent caching strategies.

Enabling Caching for Named Clients

Use AddDeliveryMemoryCache, AddDeliveryHybridCache, or AddDeliveryCacheManager to enable caching for specific named clients:

// Register named clients
services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "production-environment-id";
});

services.AddDeliveryClient("preview", options =>
{
    options.EnvironmentId = "preview-environment-id";
    options.UsePreviewApi = true;
    options.PreviewApiKey = "your-preview-api-key";
});

// Enable caching ONLY for production client
services.AddDeliveryMemoryCache("production",
    keyPrefix: "prod",
    defaultExpiration: TimeSpan.FromHours(1));

// Preview client has no cache - always fetches fresh content

Cache Key Prefixing

When multiple clients share the same underlying cache (e.g., same IMemoryCache or Redis instance), key prefixes prevent collisions:

// Both clients share IMemoryCache but have isolated entries
services.AddDeliveryMemoryCache("client1", keyPrefix: "brand-a");
services.AddDeliveryMemoryCache("client2", keyPrefix: "brand-b");

Key prefixes are automatically applied to all cache keys and dependency tracking.

Hybrid Cache for Named Clients

// Register distributed cache implementation
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

// Register client
services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "production-environment-id";
});

// Enable hybrid caching for the client
services.AddDeliveryHybridCache("production",
    keyPrefix: "prod",
    defaultExpiration: TimeSpan.FromHours(2));

Multi-Tenant Caching

When serving multiple environments or brands, use per-client caching with distinct key prefixes:

Complete Multi-Tenant Example

// Register tenant clients
services.AddDeliveryClient("tenant-a", options =>
{
    options.EnvironmentId = "tenant-a-environment-id";
});

services.AddDeliveryClient("tenant-b", options =>
{
    options.EnvironmentId = "tenant-b-environment-id";
});

// Configure caching for each tenant (order doesn't matter)
services.AddDeliveryMemoryCache("tenant-a", keyPrefix: "tenant-a");
services.AddDeliveryMemoryCache("tenant-b", keyPrefix: "tenant-b");

// Access clients via factory
var factory = serviceProvider.GetRequiredService<IDeliveryClientFactory>();
var tenantAClient = factory.Get("tenant-a");
var tenantBClient = factory.Get("tenant-b");

Per-Tenant Cache Invalidation

public class TenantCacheService
{
    private readonly IServiceProvider _serviceProvider;

    public async Task InvalidateTenantCacheAsync(string tenantId, params string[] dependencies)
    {
        // Get the keyed cache manager for the specific tenant
        var cacheManager = _serviceProvider.GetKeyedService<IDeliveryCacheManager>(tenantId);

        if (cacheManager != null)
        {
            await cacheManager.InvalidateAsync(dependencies);
        }
    }
}

Selective Caching (Production vs Preview)

A common pattern is to cache production content while preview stays fresh. Preview clients automatically bypass cache reads/writes:

// Production: cached for performance
services.AddDeliveryMemoryCache("production",
    keyPrefix: "prod",
    defaultExpiration: TimeSpan.FromHours(2));
services.AddDeliveryClient("production", options =>
{
    options.EnvironmentId = "your-environment-id";
});

// Preview: no caching for fresh content during editing
services.AddDeliveryClient("preview", options =>
{
    options.EnvironmentId = "your-environment-id";
    options.UsePreviewApi = true;
    options.PreviewApiKey = "your-preview-api-key";
});
services.AddDeliveryMemoryCache("preview"); // Optional: preview still bypasses cache reads/writes

Best Practices

1. Choose Appropriate Expiration Times

// Frequently changing content (news, live data)
services.AddDeliveryMemoryCache("news-client", defaultExpiration: TimeSpan.FromMinutes(5));

// Moderately dynamic content (blog posts, products)
services.AddDeliveryMemoryCache("blog-client", defaultExpiration: TimeSpan.FromHours(1));

// Rarely changing content (about pages, navigation)
services.AddDeliveryMemoryCache("static-client", defaultExpiration: TimeSpan.FromHours(6));

// Very stable content (archived content, documentation)
services.AddDeliveryMemoryCache("docs-client", defaultExpiration: TimeSpan.FromDays(1));

2. Implement Webhook Invalidation

Always use webhooks in production to keep cache fresh:

  • Set up webhook endpoint
  • Verify signatures
  • Invalidate specific dependencies
  • Log invalidation events

3. Monitor Cache Performance

public class MonitoredCacheManager : IDeliveryCacheManager
{
    private readonly IDeliveryCacheManager _inner;
    private readonly ILogger _logger;
    private readonly IMetrics _metrics;

    public async Task<CacheResult<T>?> GetOrSetAsync<T>(
        string cacheKey,
        Func<CancellationToken, Task<CacheEntry<T>?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default) where T : class
    {
        var stopwatch = Stopwatch.StartNew();
        var result = await _inner.GetOrSetAsync(cacheKey, factory, expiration, cancellationToken);
        stopwatch.Stop();

        _metrics.RecordCacheAccess(result != null, stopwatch.ElapsedMilliseconds);
        _logger.LogDebug("Cache {Result} for key: {Key} in {Ms}ms",
            result != null ? "HIT/SET" : "MISS", cacheKey, stopwatch.ElapsedMilliseconds);

        return result;
    }

    // ... implement InvalidateAsync delegation
}

4. Handle Cache Failures Gracefully

public async Task<CacheResult<T>?> GetOrSetAsync<T>(
    string cacheKey,
    Func<CancellationToken, Task<CacheEntry<T>?>> factory,
    TimeSpan? expiration = null,
    CancellationToken cancellationToken = default) where T : class
{
    try
    {
        return await _inner.GetOrSetAsync(cacheKey, factory, expiration, cancellationToken);
    }
    catch (RedisConnectionException ex)
    {
        _logger.LogWarning(ex, "Redis connection failed, bypassing cache");
        // Fall back to calling the factory directly (no caching)
        var entry = await factory(cancellationToken);
        return entry is null ? null : new CacheResult<T>(entry.Value, entry.Dependencies.ToArray());
    }
}

5. Pre-Warm Cache

For critical content, pre-warm the cache on startup:

public class CacheWarmupService : IHostedService
{
    private readonly IDeliveryClient _client;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // Pre-load homepage
        await _client.GetItem("homepage").ExecuteAsync(cancellationToken);

        // Pre-load navigation
        await _client.GetItem("main_navigation").ExecuteAsync(cancellationToken);

        // Pre-load recent articles
        await _client.GetItems<Article>()
            .Where(f => f.System("type").IsEqualTo("article"))
            .OrderBy("system.last_modified", OrderingMode.Descending)
            .Limit(10)
            .ExecuteAsync(cancellationToken);
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

services.AddHostedService<CacheWarmupService>();

6. Use Eager Refresh (Stale-While-Revalidate)

The built-in FusionCache-backed cache managers support eager refresh via DeliveryCacheOptions.EagerRefreshThreshold. When set, FusionCache proactively refreshes entries in the background before they expire:

services.AddDeliveryMemoryCache("production", opts =>
{
    opts.DefaultExpiration = TimeSpan.FromMinutes(30);
    opts.EagerRefreshThreshold = 0.8f; // Refresh at 80% of TTL (24 min)
});

This returns the cached value immediately while refreshing in the background — no custom implementation needed.

7. Prevent Cache Stampede (Request Coalescing)

In high-traffic scenarios, a popular cache key can expire (or be invalidated) and cause many concurrent requests to miss the cache at the same time. If every request then calls the Delivery API, you get a spike of redundant calls (the "thundering herd" problem).

The SDK mitigates this for cached query execution by coalescing concurrent cache misses:

  • The first request performs the API call and populates the cache
  • Concurrent requests for the same cache key wait for the first request to finish (then read the cached result)

Implementation details:

  • Coalescing is scoped per IDeliveryCacheManager instance (so different named clients / cache managers do not block each other)
  • Coalescing uses an in-flight task registry per cache key (owner/waiter model), not per-key semaphores
  • In-flight entries are removed immediately when the owner fetch completes (success or failure), so cleanup is completion-based

Monitoring and Diagnostics

Optional Redis Validation Suite

The SDK test project includes an opt-in Redis integration suite (RedisCacheIntegrationTests) that validates:

  • item/type/taxonomy detail invalidation
  • item/type/taxonomy listing scope invalidation
  • cross-instance invalidation visibility using two service providers against the same Redis backend

Run it locally:

KONTENT_SDK_RUN_REDIS_TESTS=true \
KONTENT_SDK_REDIS_CONNECTION=localhost:6379 \
dotnet test Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj \
  --filter "FullyQualifiedName~RedisCacheIntegrationTests"

By default, the suite is skipped unless KONTENT_SDK_RUN_REDIS_TESTS=true is set.

Cache Hit Rate

public class CacheMetrics
{
    private long _hits;
    private long _misses;

    public double HitRate => _hits + _misses == 0
        ? 0
        : (double)_hits / (_hits + _misses);

    public void RecordHit() => Interlocked.Increment(ref _hits);
    public void RecordMiss() => Interlocked.Increment(ref _misses);
}

Cache Size Monitoring

services.AddMemoryCache(options =>
{
    options.TrackStatistics = true;  // Enable statistics tracking
});

// Access statistics
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
var stats = cache.GetCurrentStatistics();

Console.WriteLine($"Total hits: {stats.TotalHits}");
Console.WriteLine($"Total misses: {stats.TotalMisses}");
Console.WriteLine($"Current entry count: {stats.CurrentEntryCount}");

Logging

public class LoggingCacheManager : IDeliveryCacheManager
{
    private readonly IDeliveryCacheManager _inner;
    private readonly ILogger _logger;

    public async Task<CacheResult<T>?> GetOrSetAsync<T>(
        string cacheKey,
        Func<CancellationToken, Task<CacheEntry<T>?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default) where T : class
    {
        // Wrap the factory to detect cache misses
        var wasMiss = false;
        var result = await _inner.GetOrSetAsync(cacheKey, async ct =>
        {
            wasMiss = true;
            return await factory(ct);
        }, expiration, cancellationToken);

        _logger.LogInformation("Cache {Result} for key: {Key}",
            wasMiss ? "MISS+SET" : "HIT", cacheKey);

        return result;
    }

    public Task<bool> InvalidateAsync(string[] dependencyKeys, CancellationToken cancellationToken = default)
        => _inner.InvalidateAsync(dependencyKeys, cancellationToken);
}

Troubleshooting

Cache Not Working

Problem: Content is always fetched from API, not cache.

Solutions:

  1. Verify cache is registered:
var cacheManager = serviceProvider.GetKeyedService<IDeliveryCacheManager>("production");
if (cacheManager == null)
{
    // Cache not registered for "production"
}
  1. Check cache expiration isn't too short
  2. Verify queries are identical (different parameters = different cache keys)

Stale Content

Problem: Cache returns old content after updates.

Solutions:

  1. Implement webhook invalidation
  2. Reduce cache expiration time
  3. Manually invalidate after content updates

Runtime Option Changes with Existing Cache

Problem: You changed EnvironmentId or DefaultRenditionPreset at runtime, but cached responses still reflect the previous setting.

Solutions:

  1. Purge the client cache after changing runtime options
  2. Recreate the client if purging is not practical
  3. Prefer separate named clients + key prefixes for production/preview/tenant/environment splits

Memory Pressure

Problem: Application uses too much memory.

Solutions:

  1. Use hybrid cache instead of memory cache
  2. Configure cache size limits:
services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024;  // Limit number of entries
});
  1. Reduce expiration times
  2. Be selective about what you cache

Redis Connection Failures

Problem: Application crashes when Redis is unavailable.

Solutions:

  1. Graceful degradation:
try
{
    return await _cache.GetAsync(key);
}
catch (RedisConnectionException)
{
    return default;  // Fall back to API
}
  1. Configure connection resilience:
var config = ConfigurationOptions.Parse("localhost:6379");
config.AbortOnConnectFail = false;
config.ConnectRetry = 3;
config.ReconnectRetryPolicy = new ExponentialRetry(5000);

Related Documentation: