When building modern web applications with Umbraco, you'll often encounter scenarios where you need to perform asynchronous operations in your render controllers - whether it's calling external APIs, performing database queries, or handling geocoding requests. However, Umbraco's RenderController
presents a unique challenge: the default routing mechanism requires the Index()
method to be synchronous.
Umbraco's content routing system expects render controllers to have a synchronous Index() method that returns an IActionResult
. This is a fundamental requirement of how Umbraco matches content to controllers and processes the default route. The framework's routing pipeline is designed around this synchronous pattern, which can be limiting when you need to perform async operations.
/// <summary>
/// The default action to render the front-end view.
/// </summary>
public virtual IActionResult Index() => CurrentTemplate(new ContentModel(CurrentPage));
In my project I had a LocationListingPageController which inheritted from RenderController and I needed to:
All of these operations are inherently asynchronous and would benefit from proper async/await patterns to avoid blocking threads. I had originally used .GetAwaiter().GetResult()
but this had a potential for thread blocking in a worse way than this solution since I was nesting async operations.
Here's the solution I implented after a couple of chats with some fellow devs.
// The reason for this is Umbraco requires Index to be synchronous for the default route
// If Umbraco changes this in the future, we can remove this method
public override IActionResult Index()
{
return Index(null, null, null).Result;
}
[HttpGet]
public async Task<IActionResult> Index([FromQuery(Name = "postcode")] string? postcode, [FromQuery(Name = "lat")] string? lat, [FromQuery(Name = "lng")] string? lng)
{
// Async implementation here...
}
This pattern involves:
Framework Compatibility
The overloaded async method can still accept query parameters, allowing for:
In this specific case, the async method handles three scenarios:
// Postcode takes priority
if (!string.IsNullOrWhiteSpace(postcode))
{
model = await PostCodeLookup(postcode);
}
// Coordinates as fallback
else if (!string.IsNullOrWhiteSpace(lat) && !string.IsNullOrWhiteSpace(lng))
{
model = await LatLngLookup(lat, lng);
}
// Default: show all locations
else
{
model.LocationsAndActivities = await _locationsAndActivitiesService.ListAllLocationsAsync();
}
Each path involves async service calls that would be problematic to handle in a purely synchronous context.
Using .Result
on async methods can potentially cause deadlocks in certain contexts. However, in the context of ASP.NET Core controllers, this risk is minimized because:
While this pattern works, it's worth noting:
As always with my blogs, I'd love to hear if there is a better way to handle this scenario and if I get feedback I'll update this blog.
After publishing this blog and sharing it around the web, I've had a couple of comments on an alternative approach that also works.
Thanks Jack and Markus for your feedback.
[NonAction]
public override IActionResult Index()
{
throw new NotImplementedException("No implementation of Index(), should use async");
}
[HttpGet]
public async Task<IActionResult> Index([FromQuery(Name = "postcode")] string? postcode, [FromQuery(Name = "lat")] string? lat, [FromQuery(Name = "lng")] string? lng)
{
// Async implementation here...
}
This approach was recommended by Bjarke from Umbraco HQ on Github - I've not personally tested this in Umbraco 15 but there shouldn't be any reason why it wouldn't work. I hope you find this update useful and thanks for the feedback