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
- Cache Types
- Configuration
- How Caching Works
- Cache Invalidation
- Per-Client Caching
- Multi-Tenant Caching
- Best Practices
- Monitoring and Diagnostics
- Troubleshooting
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 Type | Format | Example |
|---|---|---|
| Single Item | item:{codename}:lang={lang}:depth={n}:elements={sorted} | item:homepage:lang=en-US:depth=2 |
| List Items | items:lang={lang}:depth={n}:skip={n}:limit={n}:filters={hash} | items:lang=en-US:skip=0:limit=10 |
| Single Type | type:{codename}:elements={sorted} | type:article:elements=name|codename |
| List Types | types:skip={n}:limit={n}:elements={sorted}:filters={hash} | types:skip=0:limit=25 |
| Single Taxonomy | taxonomy:{codename} | taxonomy:categories |
| List Taxonomies | taxonomies: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 listingsDeliveryCacheDependencies.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 family | Detail dependency key | Listing scope dependency key |
|---|---|---|
| Items | item_{codename} | DeliveryCacheDependencies.ItemsListScope (scope_items_list) |
| Types | type_{codename} (also tags item/item-list caches containing items of that type) | DeliveryCacheDependencies.TypesListScope (scope_types_list) |
| Taxonomies | taxonomy_{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— thetype_{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 toscope_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
- Go to Environment Settings > Webhooks
- Create a new webhook
- Set URL to:
https://yourapp.com/api/webhooks/kontent - Select events: "Publish", "Unpublish", "Archive"
- 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
IDeliveryCacheManagerinstance (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:
- Verify cache is registered:
var cacheManager = serviceProvider.GetKeyedService<IDeliveryCacheManager>("production");
if (cacheManager == null)
{
// Cache not registered for "production"
}
- Check cache expiration isn't too short
- Verify queries are identical (different parameters = different cache keys)
Stale Content
Problem: Cache returns old content after updates.
Solutions:
- Implement webhook invalidation
- Reduce cache expiration time
- 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:
- Purge the client cache after changing runtime options
- Recreate the client if purging is not practical
- Prefer separate named clients + key prefixes for production/preview/tenant/environment splits
Memory Pressure
Problem: Application uses too much memory.
Solutions:
- Use hybrid cache instead of memory cache
- Configure cache size limits:
services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // Limit number of entries
});
- Reduce expiration times
- Be selective about what you cache
Redis Connection Failures
Problem: Application crashes when Redis is unavailable.
Solutions:
- Graceful degradation:
try
{
return await _cache.GetAsync(key);
}
catch (RedisConnectionException)
{
return default; // Fall back to API
}
- Configure connection resilience:
var config = ConfigurationOptions.Parse("localhost:6379");
config.AbortOnConnectFail = false;
config.ConnectRetry = 3;
config.ReconnectRetryPolicy = new ExponentialRetry(5000);
Related Documentation: