Working Around Umbraco's RenderController Async Limitation

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.

The Problem: Umbraco's Synchronous Routing Constraint

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:

  • Call geocoding services to convert postcodes to coordinates
  • Query databases for location data based on proximity
  • Handle multiple async service calls conditionally

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.

The Workaround: The Synchronous Wrapper Pattern

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:

  • Synchronous Wrapper: The parameterless Index() method that Umbraco's routing expects
  • Async Implementation: An overloaded Index() method that handles the actual business logic asynchronously
  • Explicit Result Blocking: Using .Result to synchronously wait for the async method to complete

Why This Pattern Works

Framework Compatibility

  • Satisfies Umbraco's routing requirements by providing the expected synchronous Index() method
  • Maintains compatibility with Umbraco's content resolution and template selection mechanisms
  • Doesn't break the established controller inheritance chain

Functionality Preservation

  • Allows me to perform genuine async operations in the implementation method
  • Enables proper handling of multiple concurrent async calls (geocoding, database queries)
  • Maintains the benefits of async/await for I/O-bound operations

Query Parameter Support

The overloaded async method can still accept query parameters, allowing for:

  • Postcode-based location searches
  • Coordinate-based filtering
  • Multiple search parameter combinations

Implementation Details

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.

Important Considerations

Potential Deadlock Risk

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:

  • ASP.NET Core doesn't use SynchronizationContext by default
  • The controller execution context is designed to handle this pattern
  • We're only blocking on the top-level call, not nested async operations

Performance Impact

While this pattern works, it's worth noting:

  • The calling thread is still blocked during async operations
  • You don't get the full scalability benefits of async/await

Is there a better way?

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.

Update - 06 August 2025

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

Published on : 04 August 2025