Getting around asynchronous limitations in Optimizely (Visitor Groups etc.)
Optmizely has a powerful personalization engine that allows creating custom Audiences/Visitor groups. It comes with one limitation though. It doesn't support ASYNC operations. The below solution can work for any scenario where you cannot run async operations in Optimizely.
Here's a real world use case and how we got around it.
Personalizing user experiences often starts with knowing where your visitor is coming from. One lightweight approach is to translate the user's IP address into a zip code to personalize the experience for users depending on their location (state run promotion, etc.). In Optimizely CMS 12 (running on ASP.NET Core 8), on of the way to do this is by using middleware.
Why Middleware?
Visitor Groups has a key limitation: visitor group criteria must run synchronously. This means you can’t safely call an external API or await an async lookup when evaluating a visitor group without causing thread block calls to the external service, which rules out many IP-to-zip services. ASP.NET Core middleware, on the other hand, runs for every request (that can be filtered) and fully supports async operations. Using AsyncHelper.RunSync() as outlined here isn't a scalable solution and can cause thread pool starvation and deadlocks in your application. This makes it the ideal place for concerns like geolocation, logging, authentication, and request enrichment. By resolving the zip code once at the start of the pipeline, you avoid duplicate lookups in controllers, block controllers, or views at the same time avoid blocking calls to external services.
Step 1: Create a Request-Scoped Container
We’ll use a scoped class to store the resolved zip code so it’s easily accessible through DI.
public sealed class GeoContext
{
public string? ZipCode { get; set; }
}
Step 2: Define an IP → Zip Resolver
This is your service that turns an IP address into a zip code. You can use a 3rd-party API, MaxMind, or your own data source.
public interface IZipFromIpResolver
{
Task<string?> GetZipFromIpAsync(string ip, CancellationToken ct = default);
}
Step 3: Implement Middleware
The middleware gets the client IP, calls the resolver, and stores the result in GeoContext
public sealed class GeoZipMiddleware
{
private readonly RequestDelegate _next;
public GeoZipMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, GeoContext geo, IZipFromIpResolver resolver)
{
// Filter out any paths to avoid calling the service on these requests
var path = httpContext.Request.Path.Value ?? "";
if (!path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) &&
!path.StartsWith("/static", StringComparison.OrdinalIgnoreCase) &&
!path.StartsWith("/api/custom", StringComparison.OrdinalIgnoreCase) &&
!path.Contains("/globalassets", StringComparison.OrdinalIgnoreCase) &&
!path.Contains("/.well-known", StringComparison.OrdinalIgnoreCase) &&
!path.Contains("/siteassets", StringComparison.OrdinalIgnoreCase) &&
!path.Contains("/Errors", StringComparison.OrdinalIgnoreCase) &&
!path.Contains("/contentassets", StringComparison.OrdinalIgnoreCase)) // Optimizely blobs
{
var ip = context.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrWhiteSpace(ip))
{
try
{
// Call your GeoLocation Service
geo.ZipCode = await resolver.GetZipFromIpAsync(ip);
// You can also save it within the httpContext
httpContext.Items["ZipCode"] = geo.ZipCode;
}
catch
{
// log if needed; don’t block the request
}
}
}
await _next(context);
}
}
Step 4: Register Services & Middleware
Update Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add request-scoped container
builder.Services.AddScoped<GeoContext>();
// Add resolver implementation
// builder.Services.AddSingleton<IZipFromIpResolver, MyIpResolver>();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
// Use middleware early in the pipeline
app.UseMiddleware<GeoZipMiddleware>();
app.UseRouting();
app.UseAuthorization();
app.MapContent();
app.Run();
Step 5: Use in Controllers, Blocks, or Views
Since GeoContext is scoped, you can inject it anywhere. You can also use the httpContext.Items to retrieve the value (set in the middleware)
public override bool IsMatch(IPrincipal principal, HttpContext httpContext)
{
var zipCode = httpContext.Items["ZipCode"];
// do something with it
return true;
}
Notes & Best Practices
-
Caching: IP-to-zip lookups can be expensive. Use ISynchronizedObjectCache or a distributed cache to reduce API calls.
-
Privacy: Treat IP and location data as personal information (GDPR/CCPA). Avoid persisting unless you have a clear use case.
-
Performance: Consider skipping static asset and other URL requests inside your middleware for speed.
Wrapping Up
By leveraging ASP.NET Core middleware in Optimizely CMS 12, you can easily enrich each request with geolocation or any other data. This is powerful and can significantly improve the performance of the website by removing blocking calls from the application.
Interesting approach and nice write up Aniket. Thanks for sharing!